Compare commits

...

10 Commits

Author SHA1 Message Date
Tom Boullay 606df93b69 fix: harden upload resilience and contracts 2026-05-12 23:49:30 +02:00
Tom Boullay 101af23418 docs: clean upload documentation 2026-05-12 23:48:47 +02:00
Tom Boullay 498765db61 refactor: clean upload pipeline and restore draco delivery 2026-04-29 16:29:32 +02:00
Tom Boullay 097b8f6486 docs: document texture collision handling 2026-04-28 16:03:04 +02:00
Tom Boullay a7155547c5 fix: preserve colliding exported texture variants 2026-04-28 16:02:15 +02:00
Tom Boullay 41e04002b8 fix: normalize exported texture filenames 2026-04-28 15:51:15 +02:00
Tom Boullay 497b0853c5 Update README.md 2026-04-28 00:17:45 +02:00
Tom Boullay 9dc0232e4a fix: validate texture asset names server-side 2026-04-28 00:17:28 +02:00
Tom Boullay 2679d29ab4 update: fix readme + add license 2026-04-27 23:57:12 +02:00
Tom Boullay dddecbb11c chore: prepare v1.0.0 release 2026-04-27 23:43:16 +02:00
44 changed files with 1633 additions and 670 deletions
+1
View File
@@ -1,3 +1,4 @@
*.glb filter=lfs diff=lfs merge=lfs -text
*.gltf filter=lfs diff=lfs merge=lfs -text
*.bin filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
+7 -6
View File
@@ -1,7 +1,4 @@
# =============================================================================
# Upload GLTF — Dockerfile for Coolify
# Node 20 Debian · Multi-stage build
# =============================================================================
# Coolify production image: Next.js standalone app with Blender for Draco compression.
# --- Stage 1: Dependencies ---------------------------------------------------
FROM node:20-slim AS deps
@@ -28,10 +25,11 @@ RUN npm run build
FROM node:20-slim AS runner
LABEL maintainer="La Fabrik Durable"
LABEL description="Secure GLTF upload interface with texture compression and GitHub push"
LABEL description="Secure GLTF upload interface with Draco compression and GitHub push"
# Install runtime helpers
# Install Blender (headless) + runtime helpers
RUN apt-get update && apt-get install -y --no-install-recommends \
blender \
tini \
curl \
&& rm -rf /var/lib/apt/lists/*
@@ -48,6 +46,9 @@ 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
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 La Fabrik Durable
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+97 -103
View File
@@ -2,58 +2,20 @@
A secure web interface for uploading `model.gltf` with its associated `.bin` file and textures with two outputs:
- **Nextcloud Drive** — Archives the original files with automatic versioning (VF/V1/V2...), so artists always have a history of past versions.
- **GitHub** — Delivers GLTF assets and compressed textures to the dev team's repository, ready for integration.
- **Nextcloud Drive** — Archives the original files with automatic versioning (VF/V1/V2...), so artists always have a history of past versions
- **GitHub** — Delivers Draco-compressed GLB assets by default, with an optional GLTF delivery mode for specific models
Built for La Fabrik Durable.
Built for La Fabrik Durable's internal use, but open-sourced for anyone looking for a similar solution. The app validates the upload locally, stages it server-side, then compares file diffs to avoid unnecessary uploads and commits. The Drive upload serves as the source of truth and version history, while the GitHub upload delivers the prepared assets to developers.
## Stack
- **Next.js 16** (App Router) + React 19 + TypeScript
- **Three.js** (@react-three/fiber + @react-three/drei) for 3D preview
- **Tailwind CSS** for styling
- **Octokit** for pushing via the GitHub API
- **Nextcloud WebDAV** for Drive archiving with automatic versioning
- **Sharp** for server-side texture compression
- **Coolify** (Docker) for hosting
## Installation
```bash
git clone https://github.com/La-Fabrik-Durable/upload-GLTF.git
cd upload-GLTF
npm install
```
## Configuration
Copy `.env.example` to `.env.local` and fill in the values:
```env
UPLOAD_SECRET_KEY=your-secret-key-here
GITHUB_TOKEN=ghp_your-github-personal-access-token
GIT_BRANCH=main
GIT_REPO_URL=https://github.com/your-org/your-repo.git
# Nextcloud Drive (public share WebDAV)
NEXTCLOUD_URL=https://cloud.example.com
NEXTCLOUD_SHARE_TOKEN=your-public-share-token
NEXTCLOUD_SHARE_PASSWORD=
NEXTCLOUD_BASE_PATH=Models
```
| Variable | Description | Required |
|----------|-------------|----------|
| `UPLOAD_SECRET_KEY` | Secret key for upload authentication | Yes |
| `GITHUB_TOKEN` | GitHub Personal Access Token (fine-grained, `Contents: Read and write`) | Yes |
| `GIT_BRANCH` | Target branch (default: main) | No |
| `GIT_REPO_URL` | Target GitHub repository URL | Yes |
| `NEXTCLOUD_URL` | Nextcloud instance URL | Yes |
| `NEXTCLOUD_SHARE_TOKEN` | Public share token (the part after `/s/` in the share link) | Yes |
| `NEXTCLOUD_SHARE_PASSWORD` | Public share password (empty if none) | No |
| `NEXTCLOUD_BASE_PATH` | Root folder on the Drive (default: `Models`) | No |
> To create a GitHub token: GitHub > Settings > Developer settings > Fine-grained personal access tokens > select the target repo > Permissions > Contents: Read and write.
- [**Next.js 16** (App Router)](https://nextjs.org/docs/app/getting-started/installation) + [React 19](https://react.dev/learn/creating-a-react-app) + [TypeScript](https://www.typescriptlang.org/docs/)
- [**Three.js**](https://threejs.org/docs/#manual/en/introduction/Creating-a-scene) ([React Three Fiber](https://r3f.docs.pmnd.rs/getting-started/introduction) + [Drei](https://drei.docs.pmnd.rs/getting-started/introduction)) for 3D preview
- [**Tailwind CSS**](https://v3.tailwindcss.com/docs/installation) for styling
- [**Octokit**](https://github.com/octokit/rest.js/#readme) for pushing via the GitHub API
- [**Nextcloud WebDAV**](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/index.html) for Drive archiving with automatic versioning
- [**Sharp**](https://sharp.pixelplumbing.com/install/) for server-side texture compression
- [**Coolify** (Docker)](https://coolify.io/docs/applications/build-packs/dockerfile) for hosting
## Usage
@@ -62,40 +24,7 @@ NEXTCLOUD_BASE_PATH=Models
```bash
npm run dev
```
Access the app at `http://localhost:3000`
> **Note:** Uploads accept `model.gltf` and preserve it as GLTF. `.glb` uploads are rejected by validation.
>
> Local 3D preview supports `model.gltf` folders by resolving dropped companion files such as `model.bin` and textures through local object URLs.
> The preview also shows a small model stats helper with estimated draw calls, meshes, triangles, materials, and texture count.
> Opacity helper textures can be named `opacity.png` for the whole model or `opacity_part-name.png` to target a mesh/material whose name contains `part-name`; alpha-channel PNGs are converted to alpha maps for the preview.
> If `model.gltf` references a missing `.bin` but the folder contains exactly one other `.bin`, the preview can use it as a local fallback and shows a warning because the final upload may still be broken until the `.bin` filename matches the GLTF reference.
### Asset naming convention
Texture filenames must start with a known asset family. Use `asset.png` to apply a texture to the whole model, or `asset_object.png` to target a specific object.
Allowed families are defined in `lib/asset-naming.ts`: `color`, `diffuse`, `roughness`, `normal`, `metalness`, `height`, `opacity`.
Valid examples: `color.png`, `diffuse_lampe.png`, `normal_cable1.png`, `opacity_lampe.png`.
Invalid examples: `lampe_opacity.png`, `cable1_base_color.png`, `normal_opengl_cable1.png`, `metallic_pied.png`. Invalid or unknown asset names block the upload.
### 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 \
-e NEXTCLOUD_URL=https://cloud.example.com \
-e NEXTCLOUD_SHARE_TOKEN=your-share-token \
upload-gltf
```
The Docker image runs the Next.js app and server-side asset preparation in a single container.
The app runs on `http://localhost:3000` with hot reload. The upload API routes are available under `http://localhost:3000/api/upload/*`
## How it works
@@ -105,7 +34,7 @@ The Docker image runs the Next.js app and server-side asset preparation in a sin
- Any associated binary buffer (`.bin`, for example `model.bin`)
- Any associated textures (`.png/.jpg/.jpeg/.webp`)
3. The folder is validated locally. `.glb` files are not accepted.
4. On clicking "Envoyer":
4. On clicking "Envoyer" or "Envoyer en GLTF":
- The app uploads the folder once to a temporary server-side staging area
- The app prepares the final Git payload from this staging area
- The app checks the remote Git repo for existing files and computes diffs
@@ -113,10 +42,22 @@ The Docker image runs the Next.js app and server-side asset preparation in a sin
- If the folder exists and files differ, a confirmation dialog shows **only the actual changes**
- If nothing changed, the upload is skipped entirely
### Asset naming convention
Texture filenames can either use the internal convention or common GLTF export suffixes. The Git payload is normalized automatically and `model.gltf` texture URIs are rewritten to match the normalized filenames. Drive archiving keeps the original artist files.
Internal convention: use `asset.png` to apply a texture to the whole model, or `asset_object.png` to target a specific object.
Allowed families are defined in `lib/asset-naming.ts`: `color`, `diffuse`, `roughness`, `normal`, `metalness`, `height`, `opacity`, `orm`, `ao`.
Valid internal examples: `color.png`, `diffuse_lampe.png`, `normal_cable1.png`, `opacity_lampe.png`.
Accepted export examples: `lampe_baseColor.png`, `lampe_base_color.png`, `lampe_normal_opengl.png`, `lampe_metallic.png`, `lampe_occlusionRoughnessMetallic.png`, `lampe_mixed_ao.png`.
Git normalization examples: `lampe_baseColor.png` becomes `color_lampe.png`, `lampe_metallic.png` becomes `metalness_lampe.png`, `lampe_occlusionRoughnessMetallic.png` becomes `orm_lampe.png`, and `lampe_mixed_ao.png` becomes `ao_lampe.png`.
When several exported textures would normalize to the same filename, all colliding variants keep their original names to avoid data loss. For example, `chap_normal.png` and `chap_normal_opengl.png` are both kept as-is.
Invalid or unknown asset names still block the upload.
### Upload flow: Drive first, then Git
5. **Drive upload (archiving)** — Original files from the staging area are uploaded to the Nextcloud Drive with automatic versioning (see below). This serves as the artists' source of truth and version history. If the Drive upload fails, a modal asks the user whether to send to Git only or cancel entirely.
6. **Git upload (delivery to devs)** — The prepared Git payload is reused from staging: `model.gltf` and `.bin` files are preserved, textures are compressed server-side, then all changed files are pushed to GitHub in a single commit. This is what the dev team consumes in the application.
6. **Git upload (delivery to devs)** — The prepared Git payload is reused from staging. By default, Blender exports a single `model.glb` with Draco compression. If Blender cannot process a specific model, the upload falls back to the separate `model.gltf` + `.bin` + compressed textures workflow and shows a warning. For specific models, "Envoyer en GLTF" keeps that separate GLTF delivery mode from the start.
### Drive versioning (Nextcloud WebDAV)
@@ -146,6 +87,8 @@ All files are uploaded to `VF/` (not just diffs), because the move operation emp
- The upload flow prevents duplicate submissions on the client (`Envoyer`, overwrite confirmation, and "Git only" confirmation are locked while processing)
- The server applies a lightweight per-folder lock on Drive and Git routes to avoid duplicate commits and concurrent writes
- The folder is staged server-side so the browser sends the payload only once during the full upload flow
- Invalid `model.gltf` JSON or malformed `buffers` entries block the upload before remote writes
- Git LFS uploads are batched in groups of 100 objects to stay within the GitHub LFS Batch API limit
### Commit messages
@@ -156,16 +99,7 @@ All changes are pushed in a **single commit** with a grouped formatted message:
update: upload-gltf add a new model -> my-model
📦 Model
✅ model.gltf
🎨 Textures (color)
✅ color_porte.jpg (compressed)
🪶 Textures (roughness)
✅ roughness_tuyaux.png (compressed)
🧩 Assets
✅ model.bin
✅ opacity_fenetre.png (compressed)
✅ model.glb (compressed)
```
**Update (only one texture changed):**
@@ -173,10 +107,7 @@ update: upload-gltf add a new model -> my-model
update: upload-gltf update -> coffeetest
📦 Model
↔️ model.gltf
🎨 Textures (color)
🔄 color_tuyaux.jpg (compressed)
🔄 model.glb (compressed)
```
Commit sections:
@@ -192,14 +123,15 @@ Commit sections:
Symbols: `✅` new — `🔄` modified — `↔️` unchanged (model always re-pushed) — `❌` deleted
7. Orphan files (present on remote but not in the new upload) are deleted in the same commit
8. `model.gltf` is pushed as-is so companion files like `model.bin` remain valid
8. Default Git delivery pushes `model.glb` when Blender compression succeeds. If Blender fails, the app falls back to separate `model.gltf`, `.bin`, and compressed textures with a warning. "Envoyer en GLTF" always uses separate GLTF delivery.
Uploaded models are pushed to `public/models/<folderName>/` in the target repo.
## Limitations
- Large uploads are faster than before because the folder is staged only once, but the Drive upload remains sequential.
- Git LFS uploads are still sequential.
- Large uploads are staged once, but the Drive upload remains sequential.
- Git LFS batch uploads are sequential by batch.
- Default GLB Draco delivery reduces Git LFS usage by replacing many support files with one compressed model file.
- Uploads expect a single `model.gltf` file plus optional flat support files (`.bin`, `.png`, `.jpg`, `.jpeg`, `.webp`).
## Project Structure
@@ -240,6 +172,7 @@ lib/
├── types.ts # Server types (ParsedFile, FileDiff, staged asset metadata, etc.)
├── client-types.ts # Client types (FolderEntry, DriveStatus, etc.)
├── upload-api.ts # Client-side API helpers (stage, check, uploadDrive, uploadGit)
├── guards.ts # Shared runtime guards and error message helpers
├── diff-files.ts # File diff classification (new/changed/unchanged/deleted)
├── sanitize.ts # Filename sanitization
├── auth.ts # Upload secret validation (timing-safe)
@@ -249,14 +182,71 @@ lib/
├── upload-lock.ts # Lightweight in-memory per-folder upload lock
├── asset-classification.ts # Group assets by family for commit messages
├── asset-naming.ts # Allowed asset families and naming convention helpers
├── blender.ts # Blender Draco compression helper
├── commit-message.ts # Commit message builder
├── parse-upload.ts # FormData parser + validation
├── validate-folder.ts # Client-side folder validation (discriminated union)
└── format-bytes.ts # Byte formatting utility
Dockerfile # Multi-stage build: Node 20 slim + tini
scripts/
└── compress.py # Blender Draco compression script
Dockerfile # Multi-stage build: Node 20 slim + Blender + tini
docker-entrypoint.sh # Startup check + launch
```
## Installation
```bash
git clone https://github.com/La-Fabrik-Durable/upload-GLTF.git
cd upload-GLTF
npm install
```
## Configuration
Copy `.env.example` to `.env.local` and fill in the values:
```env
UPLOAD_SECRET_KEY=your-secret-key-here
GITHUB_TOKEN=ghp_your-github-personal-access-token
GIT_BRANCH=main
GIT_REPO_URL=https://github.com/your-org/your-repo.git
# Nextcloud Drive (public share WebDAV)
NEXTCLOUD_URL=https://cloud.example.com
NEXTCLOUD_SHARE_TOKEN=your-public-share-token
NEXTCLOUD_SHARE_PASSWORD=
NEXTCLOUD_BASE_PATH=Models
```
| Variable | Description | Required |
|----------|-------------|----------|
| `UPLOAD_SECRET_KEY` | Secret key for upload authentication | Yes |
| `GITHUB_TOKEN` | GitHub Personal Access Token (fine-grained, `Contents: Read and write`) | Yes |
| `GIT_BRANCH` | Target branch (default: main) | No |
| `GIT_REPO_URL` | Target GitHub repository URL (`owner/repo`, HTTPS, or SSH) | Yes |
| `NEXTCLOUD_URL` | Nextcloud instance URL | Yes |
| `NEXTCLOUD_SHARE_TOKEN` | Public share token (the part after `/s/` in the share link) | Yes |
| `NEXTCLOUD_SHARE_PASSWORD` | Public share password (empty if none) | No |
| `NEXTCLOUD_BASE_PATH` | Root folder on the Drive (default: `Models`) | No |
> To create a GitHub token: GitHub > Settings > Developer settings > Fine-grained personal access tokens > select the target repo > Permissions > Contents: Read and write
> To create a Nextcloud public share token: Nextcloud > Files > select folder > Share > Create public share > set permissions (write access required) > copy the share link and extract the token
### 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 \
-e NEXTCLOUD_URL=https://cloud.example.com \
-e NEXTCLOUD_SHARE_TOKEN=your-share-token \
upload-gltf
```
The Docker image runs the Next.js app, Blender Draco compression, and server-side asset preparation in a single container. The `docker-entrypoint.sh` script creates the upload temp directory and reports Blender availability before launching the app.
## Supported Formats
| Type | Extensions |
@@ -265,6 +255,10 @@ docker-entrypoint.sh # Startup check + launch
| Binary buffers | `.bin` |
| Textures | `.png`, `.jpg`, `.jpeg`, `.webp` |
Git delivery outputs `.glb` by default, or keeps the source `.gltf` structure when "Envoyer en GLTF" is selected.
## License
MIT
See [MIT](LICENSE) License
Copyright 2026 La Fabrik Durable. All rights reserved.
+15 -10
View File
@@ -4,7 +4,8 @@ import { getRemoteFolder } from '@/lib/github'
import { classifyFileChanges } from '@/lib/diff-files'
import { getModelFolderPath } from '@/lib/model-paths'
import { ensurePreparedStagingAssets } from '@/lib/upload-staging'
import { parseStagingRequestBody } from '@/lib/upload-request'
import { readStagingRequestBody, uploadErrorResponse } from '@/lib/upload-request'
import type { FileDiff } from '@/lib/types'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
@@ -20,15 +21,13 @@ export async function POST(req: NextRequest) {
let stagingId: string
try {
const body: unknown = await req.json()
stagingId = parseStagingRequestBody(body).stagingId
stagingId = (await readStagingRequestBody(req)).stagingId
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ success: false, error: message }, { status: 400 })
return uploadErrorResponse(err, 400)
}
try {
const { folderName, filesToPush } = await ensurePreparedStagingAssets(stagingId)
const { folderName, filesToPush, deliveryMode, compressionError } = await ensurePreparedStagingAssets(stagingId)
const folderPath = getModelFolderPath(folderName)
const { exists, files } = await getRemoteFolder(folderPath)
@@ -36,7 +35,7 @@ export async function POST(req: NextRequest) {
const remoteFileMap = new Map(files.map((file) => [file.name.toLowerCase(), file.size]))
const { fileChanges, deletedFileNames } = classifyFileChanges(filesToPush, remoteFileMap, folderPath)
const diffs: Array<{ name: string; status: 'new' | 'changed' | 'deleted' }> = []
const diffs: FileDiff[] = []
for (const [name, status] of fileChanges.entries()) {
if (status === 'new' || status === 'changed') {
@@ -51,12 +50,18 @@ export async function POST(req: NextRequest) {
exists: true,
path: folderPath,
diffs,
deliveryMode,
compressionError,
})
}
return NextResponse.json({ success: true, exists: false })
return NextResponse.json({
success: true,
exists: false,
deliveryMode,
compressionError,
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ success: false, error: message }, { status: 500 })
return uploadErrorResponse(err, 500)
}
}
+17 -44
View File
@@ -8,64 +8,46 @@ import {
findNextVersion,
} from '@/lib/nextcloud'
import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock'
import { parseDriveRequestBody } from '@/lib/upload-request'
import {
readDriveRequestBody,
uploadErrorMessageResponse,
uploadErrorResponse,
uploadLockConflictResponse,
} from '@/lib/upload-request'
import { getErrorMessage } from '@/lib/guards'
import type { DriveAction } from '@/lib/types'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
// ---------------------------------------------------------------------------
// POST /api/upload/drive
//
// Upload **original** files to Nextcloud Drive.
//
// JSON body:
// - stagingId
// - action: "new" | "replace"
//
// Versioning logic:
// VF/{folderName} <- latest version
// V1/{folderName} <- first archive, V2/ second, etc.
//
// action="new" -> just mkdir + upload into VF/
// action="replace" -> archive VF -> Vx, then re-upload all files into VF/
// ---------------------------------------------------------------------------
export async function POST(req: NextRequest) {
// --- Auth ---
const authError = validateUploadSecret(req)
if (authError) return authError
// --- Check Nextcloud config ---
if (!process.env.NEXTCLOUD_URL || !process.env.NEXTCLOUD_SHARE_TOKEN) {
return NextResponse.json(
{ success: false, error: 'Nextcloud non configure sur le serveur (NEXTCLOUD_URL, NEXTCLOUD_SHARE_TOKEN)' },
{ status: 500 },
return uploadErrorMessageResponse(
'Nextcloud non configure sur le serveur (NEXTCLOUD_URL, NEXTCLOUD_SHARE_TOKEN)',
500,
)
}
// --- Parse staging request ---
let folderName: string
let parsedFiles: Awaited<ReturnType<typeof readStagedOriginalFiles>>['files']
let action: 'new' | 'replace'
let action: DriveAction
try {
const body: unknown = await req.json()
const parsedBody = parseDriveRequestBody(body)
const parsedBody = await readDriveRequestBody(req)
action = parsedBody.action
const stagingId = parsedBody.stagingId
const staged = await readStagedOriginalFiles(stagingId)
folderName = staged.folderName
parsedFiles = staged.files
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ success: false, error: message }, { status: 400 })
return uploadErrorResponse(err, 400)
}
if (!acquireUploadLock(folderName)) {
return NextResponse.json(
{ success: false, error: 'Un upload est deja en cours pour ce dossier. Patientez quelques secondes.' },
{ status: 409 },
)
return uploadLockConflictResponse()
}
const basePath = process.env.NEXTCLOUD_BASE_PATH || 'Models'
@@ -73,23 +55,17 @@ export async function POST(req: NextRequest) {
try {
if (action === 'replace') {
// 1. Find the next available Vx
const nextVersion = await findNextVersion(basePath, folderName)
// 2. Ensure Vx/ exists
await mkdirRecursive(`${basePath}/${nextVersion}`)
// 3. Move VF/{folderName} -> Vx/{folderName}
await moveFolder(vfFolderPath, `${basePath}/${nextVersion}/${folderName}`)
// 4. Re-create VF/{folderName}
await mkdirRecursive(vfFolderPath)
} else {
// action === 'new': just ensure VF/{folderName} exists
await mkdirRecursive(vfFolderPath)
}
// --- Upload all original files ---
for (const pf of parsedFiles) {
const remotePath = `${vfFolderPath}/${pf.filename}`
await uploadFile(remotePath, pf.buffer)
@@ -102,11 +78,8 @@ export async function POST(req: NextRequest) {
message: `${parsedFiles.length} fichier(s) envoye(s) sur le Drive.`,
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur Nextcloud inconnue'
return NextResponse.json(
{ success: false, error: `Drive echoue: ${message}` },
{ status: 500 },
)
const message = getErrorMessage(err, 'Erreur Nextcloud inconnue')
return uploadErrorMessageResponse(`Drive echoue: ${message}`, 500)
} finally {
releaseUploadLock(folderName)
}
+37 -35
View File
@@ -6,17 +6,31 @@ import { classifyFileChanges } from '@/lib/diff-files'
import { getModelFolderPath } from '@/lib/model-paths'
import { cleanupStagingUpload, ensurePreparedStagingAssets, readStagedManifest } from '@/lib/upload-staging'
import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock'
import { parseStagingRequestBody } from '@/lib/upload-request'
import {
readStagingRequestBody,
uploadErrorMessageResponse,
uploadErrorResponse,
uploadLockConflictResponse,
} from '@/lib/upload-request'
import { getErrorMessage } from '@/lib/guards'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
async function cleanupCompletedStagingUpload(stagingId: string) {
await cleanupStagingUpload(stagingId).catch((err) => {
console.warn('[WARN] Git upload -> staging cleanup failed', {
stagingId,
error: getErrorMessage(err),
})
})
}
/**
* POST /api/upload/git
* Upload prepared files and push to GitHub via Octokit.
*/
export async function POST(req: NextRequest) {
// --- Auth ---
const authError = validateUploadSecret(req)
if (authError) return authError
@@ -24,33 +38,27 @@ export async function POST(req: NextRequest) {
let stagingId: string
try {
const body: unknown = await req.json()
stagingId = parseStagingRequestBody(body).stagingId
stagingId = (await readStagingRequestBody(req)).stagingId
const manifest = await readStagedManifest(stagingId)
folderName = manifest.folderName
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ success: false, error: message }, { status: 400 })
return uploadErrorResponse(err, 400)
}
if (!acquireUploadLock(folderName)) {
return NextResponse.json(
{ success: false, error: 'Un upload est deja en cours pour ce dossier. Patientez quelques secondes.' },
{ status: 409 },
)
return uploadLockConflictResponse()
}
try {
// --- Process files (preserve model + buffers, compress textures for Git) ---
const {
filesToPush,
modelFilename,
compressed,
deliveryMode,
compressionError,
assetSummaries,
} = await ensurePreparedStagingAssets(stagingId)
// --- Detect existing files and classify changes ---
const folderPath = getModelFolderPath(folderName)
const remote = await getRemoteFolder(folderPath)
const remoteFileMap = new Map(remote.files.map((f) => [f.name.toLowerCase(), f.size]))
@@ -60,20 +68,19 @@ export async function POST(req: NextRequest) {
const { fileChanges, changedFilesToPush, deletedFileNames, deletePaths } =
classifyFileChanges(filesToPush, remoteFileMap, folderPath)
// If nothing changed, don't create an empty commit
if (changedFilesToPush.length === 0 && deletePaths.length === 0) {
await cleanupStagingUpload(stagingId).catch(() => {})
await cleanupCompletedStagingUpload(stagingId)
return NextResponse.json({
success: true,
folderName,
filesCount: 0,
compressed,
deliveryMode,
compressionError: compressionError || undefined,
message: 'Aucun fichier modifie — rien a envoyer.',
})
}
// --- Build commit message ---
const commitMessage = buildCommitMessage(
folderName,
modelFilename,
@@ -83,27 +90,22 @@ export async function POST(req: NextRequest) {
deletedFileNames,
)
// --- Push all in one commit ---
try {
const { commitUrl } = await pushAllToGitHub(changedFilesToPush, deletePaths, commitMessage)
await cleanupStagingUpload(stagingId).catch(() => {})
const { commitUrl } = await pushAllToGitHub(changedFilesToPush, deletePaths, commitMessage)
await cleanupCompletedStagingUpload(stagingId)
return NextResponse.json({
success: true,
folderName,
filesCount: changedFilesToPush.length,
compressed,
compressionError: compressionError || undefined,
message: `${changedFilesToPush.length} fichier(s) modifie(s) envoye(s) sur GitHub en un seul commit.`,
commitUrl,
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur GitHub inconnue'
return NextResponse.json(
{ success: false, error: `Push GitHub echoue: ${message}` },
{ status: 500 },
)
}
return NextResponse.json({
success: true,
folderName,
filesCount: changedFilesToPush.length,
compressed,
deliveryMode,
compressionError: compressionError || undefined,
message: `${changedFilesToPush.length} fichier(s) modifie(s) envoye(s) sur GitHub en un seul commit.`,
commitUrl,
})
} catch (err) {
const message = getErrorMessage(err, 'Erreur GitHub inconnue')
return uploadErrorMessageResponse(`Upload GitHub echoue: ${message}`, 500)
} finally {
releaseUploadLock(folderName)
}
+3 -3
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { validateUploadSecret } from '@/lib/auth'
import { parseMultiUpload } from '@/lib/parse-upload'
import { createStagingUpload } from '@/lib/upload-staging'
import { uploadErrorResponse } from '@/lib/upload-request'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
@@ -12,10 +13,9 @@ export async function POST(req: NextRequest) {
try {
const parsed = await parseMultiUpload(req)
const staged = await createStagingUpload(parsed.folderName, parsed.files)
const staged = await createStagingUpload(parsed.folderName, parsed.files, parsed.gitModelMode)
return NextResponse.json({ success: true, ...staged })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ success: false, error: message }, { status: 400 })
return uploadErrorResponse(err, 400)
}
}
+65 -7
View File
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { Component, useEffect, useState } from 'react'
import type { ComponentType, ReactNode } from 'react'
import type { ModelStats } from './SceneViewer'
interface ModelViewerProps {
@@ -10,10 +11,51 @@ interface ModelViewerProps {
size: string
}
function getPreviewErrorMessage(error: unknown) {
return error instanceof Error ? error.message : 'Erreur preview inconnue'
}
function PreviewFallback({ message }: { message?: string }) {
return (
<div className="flex h-full w-full items-center justify-center px-6 text-center">
<div className="max-w-sm space-y-2">
<p className="text-sm font-medium text-gray-300">Preview 3D indisponible pour ce modele.</p>
<p className="text-xs text-gray-500">
L&apos;upload reste possible. {message ? `Detail technique : ${message}` : ''}
</p>
</div>
</div>
)
}
class PreviewErrorBoundary extends Component<
{ children: ReactNode },
{ message: string | null }
> {
state = { message: null }
static getDerivedStateFromError(error: unknown) {
return { message: getPreviewErrorMessage(error) }
}
componentDidCatch(error: unknown) {
console.error('[ERROR] Preview 3D indisponible', error)
}
render() {
if (this.state.message) {
return <PreviewFallback message={this.state.message} />
}
return this.props.children
}
}
export default function ModelViewer({ url, assetUrls, filename, size }: ModelViewerProps) {
const canPreview = filename.toLowerCase().endsWith('.gltf')
const [stats, setStats] = useState<ModelStats | null>(null)
const [Scene, setScene] = useState<React.ComponentType<{
const [sceneError, setSceneError] = useState<string | null>(null)
const [Scene, setScene] = useState<ComponentType<{
url: string
assetUrls: Record<string, string>
onStatsReady: (stats: ModelStats) => void
@@ -23,13 +65,19 @@ export default function ModelViewer({ url, assetUrls, filename, size }: ModelVie
if (!canPreview) return
let cancel = false
setSceneError(null)
setStats(null)
import('./SceneViewer').then((mod) => {
if (!cancel) setScene(() => mod.default)
})
import('./SceneViewer')
.then((mod) => {
if (!cancel) setScene(() => mod.default)
})
.catch((error: unknown) => {
if (!cancel) setSceneError(getPreviewErrorMessage(error))
})
return () => { cancel = true }
}, [canPreview])
}, [canPreview, url])
if (!canPreview) {
return (
@@ -65,6 +113,10 @@ export default function ModelViewer({ url, assetUrls, filename, size }: ModelVie
<span className="text-gray-500">Draw calls</span>
<span className="font-mono text-gray-200">{stats.drawCalls}</span>
</span>
<span className="flex justify-between gap-3">
<span className="text-gray-500">Children</span>
<span className="font-mono text-gray-200">{stats.childObjects}</span>
</span>
<span className="flex justify-between gap-3">
<span className="text-gray-500">Meshes</span>
<span className="font-mono text-gray-200">{stats.meshes}</span>
@@ -83,7 +135,13 @@ export default function ModelViewer({ url, assetUrls, filename, size }: ModelVie
</span>
</div>
)}
<Scene url={url} assetUrls={assetUrls} onStatsReady={setStats} />
{sceneError ? (
<PreviewFallback message={sceneError} />
) : (
<PreviewErrorBoundary key={url}>
<Scene url={url} assetUrls={assetUrls} onStatsReady={setStats} />
</PreviewErrorBoundary>
)}
</div>
)
}
+16 -5
View File
@@ -7,8 +7,10 @@ import { useLoader } from '@react-three/fiber'
import { CanvasTexture, Mesh, TextureLoader } from 'three'
import type { Material, Object3D, Texture } from 'three'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { normalizeTextureFilename } from '@/lib/asset-naming'
export interface ModelStats {
childObjects: number
drawCalls: number
materials: number
meshes: number
@@ -27,6 +29,8 @@ interface AlphaMapMaterial extends Material {
alphaTest: number
}
type AlphaImageSource = HTMLImageElement | HTMLCanvasElement | ImageBitmap
const alphaMapTextureCache = new WeakMap<Texture, Texture>()
function getRequestedFilename(requestedUrl: string) {
@@ -55,7 +59,8 @@ function resolveAssetUrl(requestedUrl: string, assetUrls: Record<string, string>
function getOpacityMapEntries(assetUrls: Record<string, string>) {
return Object.entries(assetUrls).reduce<OpacityMapEntry[]>((entries, [filename, url]) => {
const match = filename.toLowerCase().match(/^opacity(?:[_-](.+))?\.(png|jpe?g|webp)$/)
const normalizedFilename = normalizeTextureFilename(filename) || filename
const match = normalizedFilename.toLowerCase().match(/^opacity(?:[_-](.+))?\.(png|jpe?g|webp)$/)
if (!match) return entries
@@ -88,14 +93,19 @@ function supportsAlphaMap(material: Material): material is AlphaMapMaterial {
return 'alphaMap' in material
}
function isAlphaImageSource(image: unknown): image is AlphaImageSource {
return image instanceof HTMLImageElement
|| image instanceof HTMLCanvasElement
|| (typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap)
}
function createAlphaMapTexture(texture: Texture) {
const cachedTexture = alphaMapTextureCache.get(texture)
if (cachedTexture) return cachedTexture
const image = texture.image as unknown
const isImageBitmap = typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap
const image = texture.image
if (!(image instanceof HTMLImageElement || image instanceof HTMLCanvasElement || isImageBitmap)) {
if (!isAlphaImageSource(image)) {
texture.flipY = false
alphaMapTextureCache.set(texture, texture)
return texture
@@ -188,6 +198,7 @@ function getModelStats(scene: Object3D, assetUrls: Record<string, string>): Mode
})
return {
childObjects: scene.children.length,
drawCalls,
materials: materials.size,
meshes,
@@ -232,7 +243,7 @@ function Model({
loader.manager.setURLModifier((requestedUrl) => resolveAssetUrl(requestedUrl, assetUrls))
})
const opacityMapEntries = getOpacityMapEntries(assetUrls)
const opacityMaps = useLoader(TextureLoader, opacityMapEntries.map((entry) => entry.url)) as Texture[]
const opacityMaps = useLoader(TextureLoader, opacityMapEntries.map((entry) => entry.url))
useEffect(() => {
onStatsReady(getModelStats(scene, assetUrls))
+3 -1
View File
@@ -91,7 +91,9 @@ export default function UploadZone() {
{' '}par exemple <span className="font-mono text-gray-200">color_porte.jpg</span>,
{' '}<span className="font-mono text-gray-200">roughness_tuyaux.png</span>,
{' '}<span className="font-mono text-gray-200">normal_dashboard.webp</span>
{' '}ou <span className="font-mono text-gray-200">opacity_fenetre.png</span>
{' '}ou <span className="font-mono text-gray-200">opacity_fenetre.png</span>.
{' '}Les exports classiques comme <span className="font-mono text-gray-200">porte_baseColor.png</span>
{' '}ou <span className="font-mono text-gray-200">porte_normal_opengl.png</span> sont normalises automatiquement pour Git.
</p>
)}
-9
View File
@@ -1,7 +1,3 @@
// ---------------------------------------------------------------------------
// Shared modal wrapper — handles overlay, centering, dialog role, aria
// ---------------------------------------------------------------------------
import type { ReactNode } from 'react'
interface ModalProps {
@@ -24,16 +20,11 @@ export default function Modal({ ariaLabelledBy, children }: ModalProps) {
)
}
// ---------------------------------------------------------------------------
// Shared modal footer with two buttons
// ---------------------------------------------------------------------------
interface ModalActionsProps {
cancelLabel: string
confirmLabel: string
onCancel: () => void
onConfirm: () => void
/** Tailwind classes for the confirm button (default: white bg) */
confirmClassName?: string
disabled?: boolean
}
-4
View File
@@ -1,7 +1,3 @@
// ---------------------------------------------------------------------------
// Shared SVG icon components
// ---------------------------------------------------------------------------
interface IconProps {
className?: string
}
+34 -15
View File
@@ -1,4 +1,5 @@
import { SpinnerIcon } from '@/components/ui/icons'
import { SpinnerIcon, WarningIcon } from '@/components/ui/icons'
import type { GitModelMode } from '@/lib/types'
interface ActionButtonsProps {
isUploading: boolean
@@ -7,7 +8,7 @@ interface ActionButtonsProps {
hasPendingOrErrors: boolean
allDone: boolean
hasErrors: boolean
onUpload: () => void
onUpload: (gitModelMode: GitModelMode) => void
onCancel: () => void
onReset: () => void
}
@@ -27,20 +28,38 @@ export default function ActionButtons({
const isBusy = isUploading || isChecking
return (
<div className="flex gap-3">
<div className="flex flex-col gap-3 sm:flex-row">
{!isBusy && hasPendingOrErrors && (
<button
onClick={onUpload}
disabled={cantUpload}
className={`flex-1 font-medium text-sm py-2.5 px-6 rounded-xl transition-all duration-150
focus:outline-none focus:ring-2 focus:ring-white/50 border border-white/20
${cantUpload
? 'bg-white/30 text-gray-500 cursor-not-allowed'
: 'bg-white text-[#000000] hover:bg-gray-200'
}`}
>
Envoyer
</button>
<>
<button
onClick={() => onUpload('draco-glb')}
disabled={cantUpload}
className={`flex-1 font-medium text-sm py-2.5 px-6 rounded-xl transition-all duration-150
focus:outline-none focus:ring-2 focus:ring-white/50 border border-white/20
${cantUpload
? 'bg-white/30 text-gray-500 cursor-not-allowed'
: 'bg-white text-[#000000] hover:bg-gray-200'
}`}
>
Envoyer
</button>
<button
onClick={() => onUpload('keep-gltf')}
disabled={cantUpload}
className={`flex-1 font-medium text-sm py-2.5 px-6 rounded-xl border transition-all duration-150
focus:outline-none focus:ring-2 focus:ring-white/30
${cantUpload
? 'bg-black-800 text-gray-600 border-white/10 cursor-not-allowed'
: 'bg-black-700 text-gray-300 border-black-600 hover:bg-black-600'
}`}
>
<span className="flex items-center justify-center gap-2">
<WarningIcon className="w-4 h-4 text-yellow-400" />
<span>Envoyer en GLTF</span>
</span>
</button>
</>
)}
{isBusy && (
+2 -6
View File
@@ -1,12 +1,8 @@
// ---------------------------------------------------------------------------
// Drive/Git status sub-line for FolderCard
// ---------------------------------------------------------------------------
import { SpinnerIcon, XIcon, WarningIcon } from '@/components/ui/icons'
import type { FolderEntry } from '@/lib/client-types'
import type { DriveStatus } from '@/lib/client-types'
interface DriveStatusLineProps {
driveStatus: NonNullable<FolderEntry['driveStatus']>
driveStatus: DriveStatus
driveError?: string
}
+9 -3
View File
@@ -1,7 +1,7 @@
import dynamic from 'next/dynamic'
import type { FolderEntry } from '@/lib/client-types'
import { formatBytes } from '@/lib/format-bytes'
import { SpinnerIcon, CheckIcon, XIcon, ChevronIcon } from '@/components/ui/icons'
import { SpinnerIcon, CheckIcon, XIcon, ChevronIcon, WarningIcon } from '@/components/ui/icons'
import DriveStatusLine from './DriveStatusLine'
import WarningBanner from './WarningBanner'
@@ -59,11 +59,17 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F
)}
</div>
{/* Drive status sub-line (only during upload, not after success) */}
{entry.status !== 'success' && entry.driveStatus && entry.driveStatus !== 'pending' && (
<DriveStatusLine driveStatus={entry.driveStatus} driveError={entry.driveError} />
)}
{entry.uploadWarning && (
<div className="mt-1.5 flex items-start gap-1.5 text-xs text-yellow-400">
<WarningIcon className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span className="line-clamp-2">{entry.uploadWarning}</span>
</div>
)}
{entry.status === 'uploading' && (
<div className="mt-1.5 w-full h-1 bg-black-700 rounded-full overflow-hidden">
<div
@@ -97,7 +103,7 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F
>
<ModelViewer
url={entry.modelUrl}
assetUrls={entry.assetUrls || {}}
assetUrls={entry.assetUrls}
filename={entry.modelFile.name}
size={formatBytes(entry.modelFile.size)}
/>
+3 -2
View File
@@ -1,6 +1,7 @@
import { useRef, useState } from 'react'
import type { FolderEntry } from '@/lib/client-types'
import { validateFolder } from '@/lib/validate-folder'
import { getErrorMessage } from '@/lib/guards'
import { FolderIcon } from '@/components/ui/icons'
function buildAssetUrls(model: File, supportFiles: File[]) {
@@ -156,8 +157,8 @@ export default function FolderDropzone({
if (droppedFiles.length === 0) return
await processFiles(droppedFiles)
} catch {
onError('Impossible de lire le dossier depose')
} catch (err) {
onError(`Impossible de lire le dossier depose: ${getErrorMessage(err)}`)
}
}
+9
View File
@@ -6,6 +6,15 @@ echo "[upload-gltf] Starting Upload GLTF..."
# Ensure tmp directory for uploads exists
mkdir -p /tmp/assets
# Check if Blender is available for Draco compression
if command -v blender > /dev/null 2>&1; then
BLENDER_VERSION=$(blender --version 2>/dev/null | head -n 1)
echo "[upload-gltf] Blender found: $BLENDER_VERSION"
echo "[upload-gltf] Draco compression is enabled."
else
echo "[upload-gltf] WARNING: Blender not found. GLB Draco compression will fall back to separate GLTF delivery."
fi
echo "[upload-gltf] Ready. Launching application..."
exec "$@"
+28 -27
View File
@@ -1,25 +1,28 @@
'use client'
// ---------------------------------------------------------------------------
// Upload orchestration hook — manages the Drive→Git upload pipeline
// ---------------------------------------------------------------------------
import { useState, useRef, useCallback } from 'react'
import { getErrorMessage } from '@/lib/guards'
import type { FolderEntry } from '@/lib/client-types'
import type { FileDiff } from '@/lib/types'
import type {
CheckUploadResult,
DriveAction,
FileDiff,
GitModelMode,
} from '@/lib/types'
import { checkFolderDiffs, stageUpload, uploadDrive, uploadGit } from '@/lib/upload-api'
import type { CheckResult } from '@/lib/upload-api'
type UploadLogDetails = Record<string, string | number | boolean | undefined>
function formatElapsed(startedAt: number) {
return `${((performance.now() - startedAt) / 1000).toFixed(1)}s`
}
function logUpload(level: 'INFO' | 'ERROR', step: string, action: string, startedAt: number, details?: Record<string, unknown>) {
function logUpload(level: 'INFO' | 'ERROR', step: string, action: string, startedAt: number, details?: UploadLogDetails) {
const log = level === 'ERROR' ? console.error : console.info
log(`[${level}] ${step} -> ${action} | Timer: ${formatElapsed(startedAt)}`, details || '')
}
function startTimedLog(step: string, action: string, details?: Record<string, unknown>) {
function startTimedLog(step: string, action: string, details?: UploadLogDetails) {
const startedAt = performance.now()
logUpload('INFO', step, `${action} started`, startedAt, details)
@@ -27,7 +30,7 @@ function startTimedLog(step: string, action: string, details?: Record<string, un
logUpload('INFO', step, `${action} running`, startedAt, details)
}, 10_000)
return (status: 'done' | 'failed' | 'cancelled' = 'done', extra?: Record<string, unknown>) => {
return (status: 'done' | 'failed' | 'cancelled' = 'done', extra?: UploadLogDetails) => {
window.clearInterval(interval)
logUpload(status === 'failed' ? 'ERROR' : 'INFO', step, `${action} ${status}`, startedAt, { ...details, ...extra })
}
@@ -63,17 +66,15 @@ export function useUploadOrchestrator({
} | null>(null)
const abortRef = useRef<AbortController | null>(null)
const checkResultRef = useRef<CheckResult>({ exists: false, diffs: [] })
const checkResultRef = useRef<CheckUploadResult>({ exists: false, diffs: [] })
const uploadActionRef = useRef(false)
const stagingIdRef = useRef<string | null>(null)
// Refs for values used inside callbacks to avoid stale closures
const secretRef = useRef(secret)
secretRef.current = secret
const entriesRef = useRef(entries)
entriesRef.current = entries
// ---- Internal: push a single folder to Git ----
const pushGit = useCallback(async (index: number, signal?: AbortSignal) => {
const stagingId = stagingIdRef.current
if (!stagingId) {
@@ -95,7 +96,7 @@ export function useUploadOrchestrator({
endGitLog(gitResult.success ? 'done' : 'failed', { error: gitResult.error })
} catch (err) {
endGitLog(signal?.aborted ? 'cancelled' : 'failed', {
error: err instanceof Error ? err.message : 'Erreur inconnue',
error: getErrorMessage(err),
})
throw err
}
@@ -104,11 +105,11 @@ export function useUploadOrchestrator({
status: gitResult.success ? 'success' : 'error',
progress: gitResult.success ? 100 : 0,
error: gitResult.success ? undefined : gitResult.error,
uploadWarning: gitResult.success ? gitResult.warning : undefined,
filename: gitResult.filename,
})
}, [updateEntry])
// ---- Main upload flow: Drive first, then Git ----
const proceedUpload = useCallback(async () => {
if (uploadActionRef.current) return
uploadActionRef.current = true
@@ -128,18 +129,18 @@ export function useUploadOrchestrator({
if (controller.signal.aborted) break
const folderEntry = currentEntries[i]
const driveAction = checkResultRef.current.exists ? 'replace' : 'new'
const driveAction: DriveAction = checkResultRef.current.exists ? 'replace' : 'new'
const stagingId = stagingIdRef.current
if (!stagingId) {
updateEntry(i, { status: 'error', error: 'Preparation serveur introuvable' })
return
}
// ---- Step 1: Drive upload ----
updateEntry(i, {
status: 'uploading',
progress: 1,
error: undefined,
uploadWarning: undefined,
driveStatus: 'uploading',
driveError: undefined,
})
@@ -155,13 +156,13 @@ export function useUploadOrchestrator({
driveResult = await uploadDrive(
stagingId,
secretRef.current,
driveAction as 'new' | 'replace',
driveAction,
controller.signal,
)
endDriveLog(driveResult.success ? 'done' : 'failed', { error: driveResult.error })
} catch (err) {
endDriveLog(controller.signal.aborted ? 'cancelled' : 'failed', {
error: err instanceof Error ? err.message : 'Erreur inconnue',
error: getErrorMessage(err),
})
throw err
}
@@ -174,7 +175,6 @@ export function useUploadOrchestrator({
updateEntry(i, { driveStatus: 'success', progress: 50 })
// ---- Step 2: Git upload ----
await pushGit(i, controller.signal)
}
} finally {
@@ -184,9 +184,7 @@ export function useUploadOrchestrator({
}
}, [updateEntry, pushGit])
// ---- Handlers ----
const handleUpload = useCallback(async () => {
const handleUpload = useCallback(async (gitModelMode: GitModelMode) => {
if (uploadActionRef.current || isChecking || isUploading) return
if (!secretRef.current.trim()) {
@@ -209,15 +207,16 @@ export function useUploadOrchestrator({
folderName: folder.folderName,
files: 1 + folder.textures.length,
modelSize: folder.modelFile.size,
gitModelMode,
})
let staged: Awaited<ReturnType<typeof stageUpload>>
try {
staged = await stageUpload(folder, secretRef.current, controller.signal)
staged = await stageUpload(folder, gitModelMode, secretRef.current, controller.signal)
endStageLog('done', { stagingId: staged.stagingId, filesCount: staged.filesCount })
} catch (err) {
endStageLog(controller.signal.aborted ? 'cancelled' : 'failed', {
error: err instanceof Error ? err.message : 'Erreur inconnue',
error: getErrorMessage(err),
})
throw err
}
@@ -228,7 +227,7 @@ export function useUploadOrchestrator({
folderName: folder.folderName,
stagingId: staged.stagingId,
})
let check: CheckResult
let check: CheckUploadResult
try {
check = await checkFolderDiffs(
@@ -239,12 +238,13 @@ export function useUploadOrchestrator({
endCheckLog('done', { exists: check.exists, diffs: check.diffs.length })
} catch (err) {
endCheckLog(controller.signal.aborted ? 'cancelled' : 'failed', {
error: err instanceof Error ? err.message : 'Erreur inconnue',
error: getErrorMessage(err),
})
throw err
}
checkResultRef.current = check
updateEntry(0, { uploadWarning: check.warning })
if (check.exists) {
if (check.diffs.length === 0) {
@@ -261,7 +261,7 @@ export function useUploadOrchestrator({
return
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
const message = getErrorMessage(err)
setGlobalError(message)
uploadActionRef.current = false
setIsChecking(false)
@@ -296,6 +296,7 @@ export function useUploadOrchestrator({
status: 'uploading',
progress: 0,
error: undefined,
uploadWarning: undefined,
driveStatus: 'skipped',
})
+2 -19
View File
@@ -1,26 +1,9 @@
import { getAssetFamily } from './asset-naming'
export type AssetCategory = 'color' | 'roughness' | 'normal' | 'metalness' | 'assets'
import type { AssetCategory } from './types'
export function classifyAssetCategory(filename: string): AssetCategory {
const name = filename.replace(/\.[^.]+$/, '')
const family = getAssetFamily(name.split('_')[0])
if (family === 'color' || family === 'diffuse') {
return 'color'
}
if (family === 'roughness') {
return 'roughness'
}
if (family === 'normal') {
return 'normal'
}
if (family === 'metalness') {
return 'metalness'
}
return 'assets'
return family || 'assets'
}
+117 -3
View File
@@ -6,9 +6,11 @@ export const ASSET_FAMILIES = [
'metalness',
'height',
'opacity',
'orm',
'ao',
] as const
export type AssetFamily = typeof ASSET_FAMILIES[number]
type AssetFamily = typeof ASSET_FAMILIES[number]
const ASSET_FAMILY_BY_KEY = new Map(ASSET_FAMILIES.map((family) => [family.toLowerCase(), family]))
const FORBIDDEN_ASSET_FAMILY_ALIASES: ReadonlyMap<string, AssetFamily> = new Map([
@@ -21,14 +23,126 @@ const FORBIDDEN_ASSET_FAMILY_ALIASES: ReadonlyMap<string, AssetFamily> = new Map
['occlusion_roughness_metallic', 'roughness'],
])
const EXPORTED_SUFFIX_ALIASES: Array<{ suffix: string; family: AssetFamily }> = [
{ suffix: 'occlusionroughnessmetallic', family: 'orm' },
{ suffix: 'occlusion_roughness_metallic', family: 'orm' },
{ suffix: 'normal_opengl', family: 'normal' },
{ suffix: 'normalopengl', family: 'normal' },
{ suffix: 'base_color', family: 'color' },
{ suffix: 'basecolor', family: 'color' },
{ suffix: 'mixed_ao', family: 'ao' },
{ suffix: 'metallic', family: 'metalness' },
{ suffix: 'roughness', family: 'roughness' },
{ suffix: 'normal', family: 'normal' },
{ suffix: 'height', family: 'height' },
{ suffix: 'opacity', family: 'opacity' },
{ suffix: 'diffuse', family: 'diffuse' },
{ suffix: 'color', family: 'color' },
]
export function getAssetFamily(value: string): AssetFamily | undefined {
return ASSET_FAMILY_BY_KEY.get(value.toLowerCase())
}
export function getForbiddenAssetFamilyAlias(value: string): AssetFamily | undefined {
function getForbiddenAssetFamilyAlias(value: string): AssetFamily | undefined {
return FORBIDDEN_ASSET_FAMILY_ALIASES.get(value.toLowerCase())
}
export function formatAssetFamilies() {
function getFileStem(filename: string) {
return filename.replace(/\.[^.]+$/, '')
}
function getFileExtension(filename: string) {
return filename.split('.').pop()?.toLowerCase() || ''
}
function normalizeTargetName(target: string) {
return target
.trim()
.replace(/[\s-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
}
function buildTextureFilename(family: AssetFamily, target: string, extension: string) {
const normalizedTarget = normalizeTargetName(target)
return `${family}${normalizedTarget ? `_${normalizedTarget}` : ''}.${extension}`
}
function getExportedTextureAlias(stem: string) {
const lowerStem = stem.toLowerCase()
for (const alias of EXPORTED_SUFFIX_ALIASES) {
const lowerSuffix = alias.suffix.toLowerCase()
if (lowerStem === lowerSuffix) {
return { family: alias.family, target: '' }
}
if (lowerStem.endsWith(`_${lowerSuffix}`)) {
return {
family: alias.family,
target: stem.slice(0, -lowerSuffix.length - 1),
}
}
}
return null
}
export function normalizeTextureFilename(filename: string) {
const stem = getFileStem(filename)
const extension = getFileExtension(filename)
const [prefix, ...targetParts] = stem.split('_')
const family = getAssetFamily(prefix)
if (family) {
return buildTextureFilename(family, targetParts.join('_'), extension)
}
const exportedAlias = getExportedTextureAlias(stem)
if (exportedAlias) {
return buildTextureFilename(exportedAlias.family, exportedAlias.target, extension)
}
return null
}
export function getTextureNamingError(filename: string) {
const stem = getFileStem(filename)
const [prefix, ...targetParts] = stem.split('_')
const family = getAssetFamily(prefix)
const extension = getFileExtension(filename)
if (normalizeTextureFilename(filename)) return null
if (family && targetParts.every(Boolean)) return null
const aliasSuggestion = getForbiddenAssetFamilyAlias(prefix)
if (aliasSuggestion && targetParts.every(Boolean)) {
const target = targetParts.join('_')
return `Convention invalide : ${filename}. Utilisez ${aliasSuggestion}_${target}.${extension} pour cibler "${target}", ou ${aliasSuggestion}.${extension} pour tout le modele.`
}
const reversedParts = stem.split('_')
const reversedFamily = reversedParts.length > 1 ? getAssetFamily(reversedParts[reversedParts.length - 1]) : undefined
const reversedAliasSuggestion = reversedParts.length > 1
? getForbiddenAssetFamilyAlias(reversedParts[reversedParts.length - 1])
: undefined
if (reversedFamily) {
const target = reversedParts.slice(0, -1).join('_')
return `Convention invalide : ${filename}. Utilisez ${reversedFamily}_${target}.${extension} pour cibler "${target}", ou ${reversedFamily}.${extension} pour tout le modele.`
}
if (reversedAliasSuggestion) {
const target = reversedParts.slice(0, -1).join('_')
return `Convention invalide : ${filename}. Utilisez ${reversedAliasSuggestion}_${target}.${extension} pour cibler "${target}", ou ${reversedAliasSuggestion}.${extension} pour tout le modele.`
}
return `Asset inconnu : ${filename}. Familles autorisees : ${formatAssetFamilies()}. Utilisez asset.png pour tout le modele ou asset_objet.png pour cibler un objet.`
}
function formatAssetFamilies() {
return ASSET_FAMILIES.join(', ')
}
+1 -2
View File
@@ -2,8 +2,7 @@ import { timingSafeEqual } from 'crypto'
import { NextRequest, NextResponse } from 'next/server'
/**
* Validate the upload secret from request headers.
* Returns null if valid, or a NextResponse error if invalid.
* Validate the shared upload secret before accepting mutation routes.
*/
export function validateUploadSecret(req: NextRequest): NextResponse | null {
const secret = req.headers.get('x-upload-secret')
+49
View File
@@ -0,0 +1,49 @@
import { join } from 'path'
import { existsSync } from 'fs'
import { execFile } from 'child_process'
import { promisify } from 'util'
const execFileAsync = promisify(execFile)
/**
* Compress a GLTF/GLB model using Blender's Draco compression.
* Returns a structured result so callers can decide whether to fall back or stop.
*/
export async function compressWithBlender(
inputPath: string,
outputPath: string,
): Promise<{ success: boolean; error?: string }> {
const blenderPath = process.env.BLENDER_PATH || 'blender'
const timeout = Number(process.env.BLENDER_TIMEOUT_MS || 600_000)
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 },
)
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}` }
}
}
+1 -1
View File
@@ -4,6 +4,6 @@ export function revokeEntryUrls(entry: FolderEntry) {
const urls = new Set<string>()
if (entry.modelUrl) urls.add(entry.modelUrl)
Object.values(entry.assetUrls || {}).forEach((url) => urls.add(url))
Object.values(entry.assetUrls).forEach((url) => urls.add(url))
urls.forEach((url) => URL.revokeObjectURL(url))
}
+4 -7
View File
@@ -1,15 +1,11 @@
// ---------------------------------------------------------------------------
// Client-side types — used by components and hooks (no Node.js Buffer)
// ---------------------------------------------------------------------------
type FileStatus = 'pending' | 'uploading' | 'success' | 'error'
export type FileStatus = 'pending' | 'uploading' | 'success' | 'error'
export interface TextureFile {
name: string
file: File
}
type DriveStatus = 'pending' | 'uploading' | 'success' | 'error' | 'skipped'
export type DriveStatus = 'pending' | 'uploading' | 'success' | 'error' | 'skipped'
export interface FolderEntry {
folderName: string
@@ -18,9 +14,10 @@ export interface FolderEntry {
status: FileStatus
progress: number
error?: string
uploadWarning?: string
filename?: string
modelUrl?: string
assetUrls?: Record<string, string>
assetUrls: Record<string, string>
viewerOpen?: boolean
warnings: string[]
driveStatus?: DriveStatus
+36 -14
View File
@@ -1,6 +1,23 @@
import type { AssetCategory } from './asset-classification'
import type { FileChange } from './types'
import type { PreparedAssetSummary } from './types'
import { ASSET_FAMILIES } from './asset-naming'
import type { AssetCategory, FileChange, PreparedAssetSummary } from './types'
const ASSET_SECTION_ORDER: AssetCategory[] = [...ASSET_FAMILIES, 'assets']
function getChangePrefix(change: FileChange) {
if (change === 'new') return '✅'
if (change === 'changed') return '🔄'
return null
}
function addGroupedAssetLine(
grouped: Map<AssetCategory, string[]>,
category: AssetCategory,
line: string,
) {
const current = grouped.get(category) || []
current.push(line)
grouped.set(category, current)
}
/**
* Build a formatted commit message based on the upload context.
@@ -25,7 +42,6 @@ export function buildCommitMessage(
const lines: string[] = [title, '']
// Model section — show status for new, changed, or unchanged
const modelSummary = assetSummaries.find((asset) => asset.kind === 'model')
const modelChange = fileChanges.get(modelFilename.toLowerCase())
if (modelChange === 'new') {
@@ -45,26 +61,32 @@ export function buildCommitMessage(
if (asset.kind === 'model' || !asset.category) continue
const change = fileChanges.get(asset.filename.toLowerCase())
if (change === 'new') {
const current = grouped.get(asset.category) || []
current.push(`${asset.filename}${asset.compressed ? ' (compressed)' : ''}`)
grouped.set(asset.category, current)
} else if (change === 'changed') {
const current = grouped.get(asset.category) || []
current.push(` 🔄 ${asset.filename}${asset.compressed ? ' (compressed)' : ''}`)
grouped.set(asset.category, current)
}
if (!change) continue
const prefix = getChangePrefix(change)
if (!prefix) continue
addGroupedAssetLine(
grouped,
asset.category,
` ${prefix} ${asset.filename}${asset.compressed ? ' (compressed)' : ''}`,
)
}
const sectionTitles: Record<AssetCategory, string> = {
color: '🎨 Textures (color)',
diffuse: '🖌 Textures (diffuse)',
roughness: '🪶 Textures (roughness)',
normal: '🧭 Textures (normal)',
metalness: '🔩 Textures (metalness)',
height: '⛰ Textures (height)',
opacity: '🪟 Textures (opacity)',
orm: '🧱 Textures (orm)',
ao: '🌑 Textures (ao)',
assets: '🧩 Assets',
}
for (const category of ['color', 'roughness', 'normal', 'metalness', 'assets'] as const) {
for (const category of ASSET_SECTION_ORDER) {
const entries = grouped.get(category)
if (!entries || entries.length === 0) continue
lines.push('')
+1 -1
View File
@@ -4,7 +4,7 @@ export const ASSET_EXTENSIONS = new Set(['.bin'])
export const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS])
/** Extensions tracked by Git LFS (must match .gitattributes) */
export const LFS_EXTENSIONS = new Set(['.gltf', '.bin', '.png', '.jpg', '.jpeg', '.webp'])
export const LFS_EXTENSIONS = new Set(['.glb', '.gltf', '.bin', '.png', '.jpg', '.jpeg', '.webp'])
export const TMP_DIR = '/tmp/assets'
+2 -16
View File
@@ -1,18 +1,10 @@
// ---------------------------------------------------------------------------
// File diff classification — compares local files against a remote file map
// ---------------------------------------------------------------------------
import { MODEL_EXTENSIONS } from './constants'
import type { FileChange, PushFile } from './types'
export interface DiffResult {
/** Map of lowercase filename → change status (for commit message) */
interface DiffResult {
fileChanges: Map<string, FileChange>
/** Files that actually need to be pushed (new or changed) */
changedFilesToPush: PushFile[]
/** Filenames that were on remote but not in the new upload */
deletedFileNames: string[]
/** Full paths for deletion on remote */
deletePaths: string[]
}
@@ -21,9 +13,7 @@ export interface DiffResult {
* the remote file map.
*
* Rules:
* - Models: always re-pushed,
* but marked as 'unchanged' in the commit message when the folder already
* exists (we keep the current behavior of always delivering the model file).
* - Models: always re-pushed, but marked as unchanged when the remote file exists.
* - Textures: compared by size (not compressed, reliable).
* - Orphan remote files: classified as deletions.
*/
@@ -41,13 +31,10 @@ export function classifyFileChanges(
const isModel = MODEL_EXTENSIONS.has(ext)
if (isModel) {
// Model: always re-push since compression makes size comparison unreliable.
// Mark as 'unchanged' for the commit message when the folder already exists.
const remoteSize = remoteFileMap.get(filename.toLowerCase())
fileChanges.set(filename.toLowerCase(), remoteSize === undefined ? 'new' : 'unchanged')
changedFilesToPush.push(f)
} else {
// Texture: compare by size
const localSize = Buffer.from(f.contentBase64, 'base64').length
const remoteSize = remoteFileMap.get(filename.toLowerCase())
@@ -63,7 +50,6 @@ export function classifyFileChanges(
}
}
// Files on remote not in the new upload → deleted (orphans)
const newFileNames = new Set(filesToPush.map((f) => (f.path.split('/').pop() ?? '').toLowerCase()))
const deletedFileNames: string[] = []
const deletePaths: string[] = []
-4
View File
@@ -1,7 +1,3 @@
// ---------------------------------------------------------------------------
// Format bytes to human-readable string
// ---------------------------------------------------------------------------
export function formatBytes(bytes: number): string {
if (bytes <= 0) return '0 B'
const k = 1024
+90 -65
View File
@@ -1,16 +1,15 @@
import { createHash } from 'crypto'
import { Octokit } from '@octokit/rest'
import { LFS_EXTENSIONS } from './constants'
import { isRecord } from './guards'
import type { PushFile, RemoteFile } from './types'
const LFS_BATCH_SIZE = 100
// ---------------------------------------------------------------------------
// Octokit helpers
// ---------------------------------------------------------------------------
type LogDetails = Record<string, string | number | boolean | undefined>
function isHttpError(err: unknown): err is { status: number } {
return typeof err === 'object' && err !== null && 'status' in err && typeof (err as Record<string, unknown>).status === 'number'
return isRecord(err) && typeof err.status === 'number'
}
function getOctokit(): Octokit {
@@ -20,35 +19,44 @@ function getOctokit(): Octokit {
}
function parseRepoUrl(): { owner: string; repo: string } {
const url = process.env.GIT_REPO_URL
const url = process.env.GIT_REPO_URL?.trim()
if (!url) throw new Error('GIT_REPO_URL non configure')
const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/.]+)/)
const sshMatch = url.match(/github\.com:([^/]+)\/([^/.]+)/)
const shortMatch = url.match(/^([^/]+)\/([^/]+)$/)
const cleanRepoName = (repo: string) => repo.replace(/\/+$/, '').replace(/\.git$/, '')
const shortMatch = url.match(/^([^/\s:]+)\/([^/\s]+)$/)
if (shortMatch) {
return { owner: shortMatch[1], repo: cleanRepoName(shortMatch[2]) }
}
const match = httpsMatch || sshMatch || shortMatch
if (!match) throw new Error(`Format GIT_REPO_URL invalide: "${url}"`)
const sshMatch = url.match(/github\.com:([^/\s]+)\/(.+)$/)
if (sshMatch) {
return { owner: sshMatch[1], repo: cleanRepoName(sshMatch[2]) }
}
return { owner: match[1], repo: match[2] }
if (URL.canParse(url)) {
const parsed = new URL(url)
const pathParts = parsed.pathname
.replace(/^\/+|\/+$/g, '')
.split('/')
.filter(Boolean)
if (parsed.hostname === 'github.com' && pathParts.length >= 2) {
return { owner: pathParts[0], repo: cleanRepoName(pathParts[1]) }
}
}
throw new Error(`Format GIT_REPO_URL invalide: "${url}"`)
}
// ---------------------------------------------------------------------------
// Git LFS helpers
// ---------------------------------------------------------------------------
/** Check if a file path should be tracked by LFS based on its extension. */
function isLfsFile(filePath: string): boolean {
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase()
return LFS_EXTENSIONS.has(ext)
}
/** Build an LFS pointer file (text content stored in the Git blob). */
function buildLfsPointer(sha256: string, size: number): string {
return `version https://git-lfs.github.com/spec/v1\noid sha256:${sha256}\nsize ${size}\n`
}
/** Parse an LFS pointer to extract the real file size. Returns null if not a pointer. */
function parseLfsPointer(content: string): { oid: string; size: number } | null {
if (!content.startsWith('version https://git-lfs.github.com/spec/v1')) return null
const sizeMatch = content.match(/^size (\d+)$/m)
@@ -61,7 +69,7 @@ function formatElapsed(startedAt: number) {
return `${((performance.now() - startedAt) / 1000).toFixed(1)}s`
}
function logInfo(step: string, action: string, startedAt: number, details?: Record<string, unknown>) {
function logInfo(step: string, action: string, startedAt: number, details?: LogDetails) {
console.info(`[INFO] ${step} -> ${action} | Timer: ${formatElapsed(startedAt)}`, details || '')
}
@@ -81,14 +89,67 @@ interface LfsObject {
contentBase64: string
}
/**
* Upload binary objects to the Git LFS server via the Batch API.
*
* Flow:
* 1. POST to the LFS batch endpoint with operation "upload"
* 2. For each object that has an "upload" action, PUT the binary content
* 3. If the server omits "actions", the object already exists — skip upload
*/
interface LfsBatchAction {
href: string
header?: Record<string, string>
}
interface LfsBatchObject {
oid: string
size: number
actions?: {
upload?: LfsBatchAction
verify?: LfsBatchAction
}
error?: { code: number; message: string }
}
function isStringRecord(value: unknown): value is Record<string, string> {
return isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string')
}
function parseLfsAction(value: unknown): LfsBatchAction | undefined {
if (!isRecord(value) || typeof value.href !== 'string') return undefined
return {
href: value.href,
header: isStringRecord(value.header) ? value.header : undefined,
}
}
function parseLfsBatchObject(value: unknown): LfsBatchObject | null {
if (!isRecord(value) || typeof value.oid !== 'string' || typeof value.size !== 'number') return null
const actions = isRecord(value.actions)
? {
upload: parseLfsAction(value.actions.upload),
verify: parseLfsAction(value.actions.verify),
}
: undefined
const error = isRecord(value.error) && typeof value.error.code === 'number' && typeof value.error.message === 'string'
? { code: value.error.code, message: value.error.message }
: undefined
return { oid: value.oid, size: value.size, actions, error }
}
function parseLfsBatchResponse(value: unknown): LfsBatchObject[] {
if (!isRecord(value) || !Array.isArray(value.objects)) {
throw new Error('LFS batch response invalide')
}
const objects: LfsBatchObject[] = []
for (const object of value.objects) {
const parsed = parseLfsBatchObject(object)
if (!parsed) {
throw new Error('LFS batch object invalide')
}
objects.push(parsed)
}
return objects
}
async function uploadToLfs(
owner: string,
repo: string,
@@ -118,7 +179,6 @@ async function uploadToLfsBatch(
const token = process.env.GITHUB_TOKEN!
const lfsUrl = `https://github.com/${owner}/${repo}.git/info/lfs/objects/batch`
// 1. Batch request — ask for upload URLs
const batchRes = await fetch(lfsUrl, {
method: 'POST',
headers: {
@@ -142,27 +202,15 @@ async function uploadToLfsBatch(
throw new Error(`LFS batch request failed (${batchRes.status}): ${text}`)
}
const batchData = (await batchRes.json()) as {
objects: Array<{
oid: string
size: number
actions?: {
upload?: { href: string; header?: Record<string, string> }
verify?: { href: string; header?: Record<string, string> }
}
error?: { code: number; message: string }
}>
}
const batchObjects = parseLfsBatchResponse(await batchRes.json())
// 2. Upload each object that has an upload action
const objectMap = new Map(objects.map((o) => [o.oid, o]))
for (const obj of batchData.objects) {
for (const obj of batchObjects) {
if (obj.error) {
throw new Error(`LFS error for ${obj.oid}: ${obj.error.message} (${obj.error.code})`)
}
// No actions = server already has this object, skip
if (!obj.actions?.upload) continue
const local = objectMap.get(obj.oid)
@@ -187,7 +235,6 @@ async function uploadToLfsBatch(
throw new Error(`LFS upload failed for ${obj.oid} (${uploadRes.status}): ${text}`)
}
// 3. Verify if required
if (obj.actions.verify) {
const verifyAction = obj.actions.verify
const verifyHeaders: Record<string, string> = {
@@ -214,10 +261,6 @@ async function uploadToLfsBatch(
})
}
// ---------------------------------------------------------------------------
// Read remote folder contents (with real file sizes for LFS files)
// ---------------------------------------------------------------------------
export async function getRemoteFolder(
folderPath: string,
): Promise<{ exists: boolean; files: RemoteFile[] }> {
@@ -237,16 +280,12 @@ export async function getRemoteFolder(
return { exists: false, files: [] }
}
// For LFS-tracked files, the "size" from getContent is the pointer size (~130 bytes),
// not the real file size. We need to fetch each LFS pointer to get the real size.
const files: RemoteFile[] = await Promise.all(
data.map(async (f): Promise<RemoteFile> => {
if (!isLfsFile(f.name) || f.size > 1024) {
// Not LFS or too large to be a pointer — use size as-is
return { name: f.name, size: f.size }
}
// Fetch the blob content to check if it's an LFS pointer
try {
const { data: fileData } = await octokit.repos.getContent({
owner,
@@ -279,10 +318,6 @@ export async function getRemoteFolder(
}
}
// ---------------------------------------------------------------------------
// Push all files in a single commit (with optional deletions + LFS support)
// ---------------------------------------------------------------------------
export async function pushAllToGitHub(
files: PushFile[],
deletePaths: string[],
@@ -292,7 +327,6 @@ export async function pushAllToGitHub(
const { owner, repo } = parseRepoUrl()
const branch = process.env.GIT_BRANCH ?? 'main'
// --- Separate LFS files from regular files ---
const lfsFiles: { path: string; contentBase64: string; oid: string; size: number }[] = []
const regularFiles: PushFile[] = []
@@ -306,7 +340,6 @@ export async function pushAllToGitHub(
}
}
// --- Upload LFS objects to the LFS server ---
if (lfsFiles.length > 0) {
await uploadToLfs(
owner,
@@ -315,7 +348,6 @@ export async function pushAllToGitHub(
)
}
// 1. Get latest commit on branch
const { data: ref } = await octokit.git.getRef({
owner,
repo,
@@ -323,21 +355,18 @@ export async function pushAllToGitHub(
})
const latestCommitSha = ref.object.sha
// 2. Get that commit's tree
const { data: commit } = await octokit.git.getCommit({
owner,
repo,
commit_sha: latestCommitSha,
})
// 3. Create blobs — LFS files get pointer blobs, regular files get raw blobs
const allFiles = [...regularFiles, ...lfsFiles]
const blobResults = await Promise.all(
allFiles.map((f) => {
const lfs = lfsFiles.find((lf) => lf.path === f.path)
if (lfs) {
// Create a blob with the LFS pointer text (NOT the binary content)
const pointer = buildLfsPointer(lfs.oid, lfs.size)
return octokit.git.createBlob({
owner,
@@ -346,7 +375,6 @@ export async function pushAllToGitHub(
encoding: 'base64',
})
}
// Regular file — push content as-is
return octokit.git.createBlob({
owner,
repo,
@@ -356,7 +384,6 @@ export async function pushAllToGitHub(
}),
)
// 4. Build tree entries: new/changed files + deletions
const newFilePaths = new Set(files.map((f) => f.path))
const deleteEntries = deletePaths
.filter((p) => !newFilePaths.has(p))
@@ -382,7 +409,6 @@ export async function pushAllToGitHub(
],
})
// 5. Create a single commit
const { data: newCommit } = await octokit.git.createCommit({
owner,
repo,
@@ -391,7 +417,6 @@ export async function pushAllToGitHub(
parents: [latestCommitSha],
})
// 6. Update branch ref
await octokit.git.updateRef({
owner,
repo,
+7
View File
@@ -0,0 +1,7 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export function getErrorMessage(error: unknown, fallback = 'Erreur inconnue') {
return error instanceof Error ? error.message : fallback
}
+1 -32
View File
@@ -1,11 +1,5 @@
// ---------------------------------------------------------------------------
// Nextcloud WebDAV client
// Uses native fetch — no npm package needed.
// ---------------------------------------------------------------------------
const MAX_VERSIONS = 1000
// Lazy-cached config to avoid recomputing on every request
let cachedConfig: { davBase: string; auth: string } | null = null
function getConfig() {
@@ -19,7 +13,6 @@ function getConfig() {
throw new Error('Nextcloud non configure (NEXTCLOUD_URL, NEXTCLOUD_SHARE_TOKEN)')
}
// Public share WebDAV: https://cloud.example.com/public.php/webdav/
const davBase = `${url.replace(/\/+$/, '')}/public.php/webdav`
const auth = 'Basic ' + Buffer.from(`${token}:${password}`).toString('base64')
@@ -32,10 +25,6 @@ function davUrl(davBase: string, path: string): string {
return `${davBase}/${clean}`
}
// ---------------------------------------------------------------------------
// Low-level WebDAV helpers
// ---------------------------------------------------------------------------
async function davRequest(
method: string,
path: string,
@@ -57,12 +46,7 @@ async function davRequest(
return res
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Check if a folder exists on the Nextcloud instance. */
export async function folderExists(path: string): Promise<boolean> {
async function folderExists(path: string): Promise<boolean> {
const res = await davRequest('PROPFIND', path + '/', null, { Depth: '0' })
if (res.status === 404) return false
@@ -72,10 +56,6 @@ export async function folderExists(path: string): Promise<boolean> {
throw new Error(`PROPFIND ${path} failed (${res.status}): ${text.slice(0, 200)}`)
}
/**
* Create a folder and all parent segments if they don't exist.
* Like `mkdir -p`. Attempts MKCOL directly and handles 405 (already exists).
*/
export async function mkdirRecursive(path: string): Promise<void> {
const segments = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/')
let current = ''
@@ -84,14 +64,12 @@ export async function mkdirRecursive(path: string): Promise<void> {
current += '/' + seg
const res = await davRequest('MKCOL', current + '/')
if (res.status !== 201 && res.status !== 405) {
// 201 = created, 405 = already exists — both are fine
const text = await res.text().catch(() => '')
throw new Error(`MKCOL ${current} failed (${res.status}): ${text.slice(0, 200)}`)
}
}
}
/** Upload a file (overwrite if exists). */
export async function uploadFile(path: string, content: Buffer): Promise<void> {
const res = await davRequest('PUT', path, content, {
'Content-Type': 'application/octet-stream',
@@ -104,7 +82,6 @@ export async function uploadFile(path: string, content: Buffer): Promise<void> {
}
}
/** Move (rename) a folder or file. */
export async function moveFolder(from: string, to: string): Promise<void> {
const { davBase } = getConfig()
const destination = davUrl(davBase, to) + '/'
@@ -120,14 +97,6 @@ export async function moveFolder(from: string, to: string): Promise<void> {
}
}
// ---------------------------------------------------------------------------
// High-level: find next available version folder
// ---------------------------------------------------------------------------
/**
* Find the next available Vx folder for archiving.
* E.g. if V1/coffeetest exists but V2/coffeetest doesn't, returns "V2".
*/
export async function findNextVersion(
basePath: string,
folderName: string,
+38 -28
View File
@@ -1,42 +1,40 @@
import { extname } from 'path'
import { NextRequest } from 'next/server'
import { sanitizeFilename } from './sanitize'
import { ALL_ALLOWED_EXTENSIONS, MODEL_EXTENSIONS, MAX_FILE_SIZE } from './constants'
import type { ParsedFile } from './types'
import { ALL_ALLOWED_EXTENSIONS, MODEL_EXTENSIONS, MAX_FILE_SIZE, TEXTURE_EXTENSIONS } from './constants'
import { getTextureNamingError } from './asset-naming'
import type { GitModelMode, ParsedFile } from './types'
export interface ParsedUpload {
interface ParsedUpload {
folderName: string
files: ParsedFile[]
/** Any extra string fields from the FormData (e.g. "action") */
extra: Record<string, string>
gitModelMode: GitModelMode
}
function parseGitModelMode(value: FormDataEntryValue | null): GitModelMode {
if (value === 'draco-glb' || value === 'keep-gltf') return value
throw new Error('Mode Git invalide')
}
/**
* Parse a multi-file FormData upload request.
* Validates file extensions, file sizes, and returns parsed files.
* Extra string fields (beyond folderName, files, fileTypes, textureNames)
* are returned in `extra`.
*/
export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload> {
const formData = await req.formData()
const folderValue = formData.get('folderName')
const folderName = typeof folderValue === 'string' ? folderValue.trim() || 'assets' : 'assets'
if (typeof folderValue !== 'string' || folderValue.trim() === '') {
throw new Error('Nom de dossier manquant')
}
const folderName = folderValue.trim()
const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-')
if (!safeFolderName) {
throw new Error('Nom de dossier invalide')
}
const gitModelMode = parseGitModelMode(formData.get('gitModelMode'))
const rawFiles = formData.getAll('files')
const fileTypes = formData.getAll('fileTypes').filter((value): value is string => typeof value === 'string')
const textureNames = formData.getAll('textureNames').filter((value): value is string => typeof value === 'string')
// Collect extra string fields
const knownKeys = new Set(['folderName', 'files', 'fileTypes', 'textureNames'])
const extra: Record<string, string> = {}
for (const [key, value] of formData.entries()) {
if (!knownKeys.has(key) && typeof value === 'string') {
extra[key] = value
}
}
// Runtime validation: ensure entries are actual File objects
const fileEntries: File[] = []
for (const entry of rawFiles) {
if (!(entry instanceof File)) {
@@ -56,7 +54,6 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
const file = fileEntries[i]
if (!file || file.size === 0) continue
// File size limit
if (file.size > MAX_FILE_SIZE) {
throw new Error(
`Fichier "${file.name}" trop volumineux (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum: ${MAX_FILE_SIZE / 1024 / 1024} MB.`,
@@ -67,10 +64,10 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
const texName = textureNames[i] || ''
const originalSafe = sanitizeFilename(file.name)
const ext = extname(originalSafe).toLowerCase()
const originalExt = extname(originalSafe).toLowerCase()
if (!ALL_ALLOWED_EXTENSIONS.has(ext)) {
throw new Error(`Extension non autorisee: "${ext}"`)
if (!ALL_ALLOWED_EXTENSIONS.has(originalExt)) {
throw new Error(`Extension non autorisee: "${originalExt}"`)
}
let filename: string
@@ -80,7 +77,20 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
filename = originalSafe
}
const isModel = MODEL_EXTENSIONS.has(ext)
const filenameExt = extname(filename).toLowerCase()
if (filenameExt !== originalExt) {
throw new Error(`Nom de fichier incoherent : ${filename} ne correspond pas a l'extension originale ${originalExt}`)
}
const textureNamingError = TEXTURE_EXTENSIONS.has(filenameExt)
? getTextureNamingError(filename)
: null
if (textureNamingError) {
throw new Error(textureNamingError)
}
const isModel = MODEL_EXTENSIONS.has(filenameExt)
if (isModel) {
if (filename.toLowerCase() !== 'model.gltf') {
throw new Error('Le modele doit etre nomme model.gltf')
@@ -100,5 +110,5 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
throw new Error('Un seul fichier model.gltf est autorise')
}
return { folderName: safeFolderName, files: parsed, extra }
return { folderName: safeFolderName, files: parsed, gitModelMode }
}
+186 -16
View File
@@ -1,25 +1,114 @@
import { randomUUID } from 'crypto'
import { existsSync } from 'fs'
import { mkdir, readFile, rm, writeFile } from 'fs/promises'
import { extname, join } from 'path'
import { compressWithBlender } from '@/lib/blender'
import { compressTextureBuffer } from '@/lib/texture-compression'
import { classifyAssetCategory } from '@/lib/asset-classification'
import { normalizeTextureFilename } from '@/lib/asset-naming'
import { TEXTURE_EXTENSIONS, TMP_DIR } from '@/lib/constants'
import { getErrorMessage, isRecord } from '@/lib/guards'
import { getModelAssetPath } from '@/lib/model-paths'
import type { ParsedFile, PreparedAssetSummary, PushFile } from '@/lib/types'
import type { GitModelMode, ParsedFile, PreparedAssetSummary, PreparedGitAssetsResult, PushFile } from '@/lib/types'
interface PrepareGitAssetsParams {
folderName: string
parsedFiles: ParsedFile[]
gitModelMode: GitModelMode
}
interface PrepareGitAssetsResult {
filesToPush: PushFile[]
modelFilename: string
assetSummaries: PreparedAssetSummary[]
compressed: boolean
compressionError?: string
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
function isJsonValue(value: unknown): value is JsonValue {
if (value === null) return true
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return true
}
if (Array.isArray(value)) {
return value.every(isJsonValue)
}
return isRecord(value) && Object.values(value).every(isJsonValue)
}
export async function prepareGitAssets({
folderName,
parsedFiles,
}: PrepareGitAssetsParams): Promise<PrepareGitAssetsResult> {
function parseJsonValue(content: string) {
const parsed: unknown = JSON.parse(content)
if (!isJsonValue(parsed)) {
throw new Error('model.gltf contient un JSON invalide')
}
return parsed
}
function getTextureFilenameMap(parsedFiles: ParsedFile[]) {
const filenameMap = new Map<string, string>()
const normalizedGroups = new Map<string, Array<{ original: string; normalized: string }>>()
for (const file of parsedFiles) {
const ext = extname(file.filename).toLowerCase()
if (!TEXTURE_EXTENSIONS.has(ext)) continue
const normalizedFilename = normalizeTextureFilename(file.filename)
if (!normalizedFilename) continue
const normalizedKey = normalizedFilename.toLowerCase()
const group = normalizedGroups.get(normalizedKey) || []
group.push({ original: file.filename, normalized: normalizedFilename })
normalizedGroups.set(normalizedKey, group)
}
for (const group of normalizedGroups.values()) {
if (group.length > 1) continue
const [{ original, normalized }] = group
filenameMap.set(original.toLowerCase(), normalized)
}
return filenameMap
}
function getReferencedFilename(uri: string) {
const cleanUri = decodeURIComponent(uri.split(/[?#]/)[0] || '')
return cleanUri.split(/[\\/]/).pop()?.toLowerCase()
}
function rewriteGltfUris(value: JsonValue, filenameMap: Map<string, string>): JsonValue {
if (Array.isArray(value)) {
return value.map((entry) => rewriteGltfUris(entry, filenameMap))
}
if (!value || typeof value !== 'object') return value
const rewritten: Record<string, JsonValue> = {}
for (const [key, entry] of Object.entries(value)) {
if (key === 'uri' && typeof entry === 'string') {
const filename = getReferencedFilename(entry)
rewritten[key] = filename ? filenameMap.get(filename) || entry : entry
continue
}
rewritten[key] = rewriteGltfUris(entry, filenameMap)
}
return rewritten
}
function prepareModelBuffer(buffer: Buffer, filenameMap: Map<string, string>) {
if (filenameMap.size === 0) return buffer
const parsed = parseJsonValue(buffer.toString('utf-8'))
return Buffer.from(JSON.stringify(rewriteGltfUris(parsed, filenameMap), null, 2), 'utf-8')
}
async function prepareSeparateFiles(
folderName: string,
parsedFiles: ParsedFile[],
textureFilenameMap: Map<string, string>,
) {
const filesToPush: PushFile[] = []
const assetSummaries: PreparedAssetSummary[] = []
let modelFilename = ''
@@ -28,19 +117,23 @@ export async function prepareGitAssets({
for (const pf of parsedFiles) {
let content = pf.buffer
let filename = pf.filename
if (pf.isModel) {
content = prepareModelBuffer(pf.buffer, textureFilenameMap)
modelFilename = pf.filename
assetSummaries.push({
filename: pf.filename,
filename,
kind: 'model',
compressed: false,
})
} else {
const category = classifyAssetCategory(pf.filename)
filename = textureFilenameMap.get(pf.filename.toLowerCase()) || pf.filename
const categoryFilename = textureFilenameMap.get(pf.filename.toLowerCase()) || normalizeTextureFilename(pf.filename) || pf.filename
const category = classifyAssetCategory(categoryFilename)
const textureResult = await compressTextureBuffer(pf.filename, pf.buffer)
const textureResult = await compressTextureBuffer(filename, pf.buffer)
content = textureResult.buffer
compressed ||= textureResult.compressed
@@ -49,7 +142,7 @@ export async function prepareGitAssets({
}
assetSummaries.push({
filename: pf.filename,
filename,
kind: category === 'assets' ? 'asset' : 'texture',
category,
compressed: textureResult.compressed,
@@ -57,7 +150,7 @@ export async function prepareGitAssets({
}
filesToPush.push({
path: getModelAssetPath(folderName, pf.filename),
path: getModelAssetPath(folderName, filename),
contentBase64: content.toString('base64'),
})
}
@@ -68,5 +161,82 @@ export async function prepareGitAssets({
assetSummaries,
compressed,
compressionError,
deliveryMode: 'keep-gltf' as const,
}
}
async function prepareDracoGlb(
folderName: string,
parsedFiles: ParsedFile[],
textureFilenameMap: Map<string, string>,
): Promise<PreparedGitAssetsResult> {
const tmpFolder = join(TMP_DIR, 'blender', `${folderName}-${randomUUID()}`)
const inputModelPath = join(tmpFolder, 'model.gltf')
const outputModelPath = join(tmpFolder, 'model.glb')
await mkdir(tmpFolder, { recursive: true })
try {
for (const pf of parsedFiles) {
if (pf.isModel) {
await writeFile(inputModelPath, prepareModelBuffer(pf.buffer, textureFilenameMap))
continue
}
const filename = textureFilenameMap.get(pf.filename.toLowerCase()) || pf.filename
const ext = extname(filename).toLowerCase()
const content = TEXTURE_EXTENSIONS.has(ext)
? (await compressTextureBuffer(filename, pf.buffer)).buffer
: pf.buffer
await writeFile(join(tmpFolder, filename), content)
}
try {
const result = await compressWithBlender(inputModelPath, outputModelPath)
if (!result.success || !existsSync(outputModelPath)) {
throw new Error(result.error || 'Compression Blender echouee')
}
const content = await readFile(outputModelPath)
const modelFilename = 'model.glb'
return {
filesToPush: [{
path: getModelAssetPath(folderName, modelFilename),
contentBase64: content.toString('base64'),
}],
modelFilename,
assetSummaries: [{ filename: modelFilename, kind: 'model', compressed: true }],
compressed: true,
deliveryMode: 'draco-glb',
}
} catch (err) {
const fallback = await prepareSeparateFiles(folderName, parsedFiles, textureFilenameMap)
const message = getErrorMessage(err, 'Compression Blender echouee')
return {
...fallback,
compressionError: fallback.compressionError
? `${message}. ${fallback.compressionError}`
: message,
}
}
} finally {
await rm(tmpFolder, { recursive: true, force: true }).catch(() => {})
}
}
export async function prepareGitAssets({
folderName,
parsedFiles,
gitModelMode,
}: PrepareGitAssetsParams): Promise<PreparedGitAssetsResult> {
const textureFilenameMap = getTextureFilenameMap(parsedFiles)
if (gitModelMode === 'keep-gltf') {
return prepareSeparateFiles(folderName, parsedFiles, textureFilenameMap)
}
return prepareDracoGlb(folderName, parsedFiles, textureFilenameMap)
}
+1 -2
View File
@@ -1,8 +1,7 @@
import { basename } from 'path'
/**
* Sanitize a filename: strip path components, replace special chars,
* collapse underscores, lowercase.
* Normalize uploaded filenames before storing them or writing Git paths.
*/
export function sanitizeFilename(name: string): string {
return basename(name)
+2 -1
View File
@@ -1,5 +1,6 @@
import { extname } from 'path'
import sharp from 'sharp'
import { getErrorMessage } from './guards'
interface TextureCompressionResult {
buffer: Buffer
@@ -35,7 +36,7 @@ export async function compressTextureBuffer(
}
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = getErrorMessage(err, String(err))
return {
buffer,
compressed: false,
+46 -3
View File
@@ -1,5 +1,3 @@
import type { AssetCategory } from './asset-classification'
export interface ParsedFile {
filename: string
buffer: Buffer
@@ -11,11 +9,19 @@ export interface PushFile {
contentBase64: string
}
export type GitModelMode = 'draco-glb' | 'keep-gltf'
export type DriveAction = 'new' | 'replace'
export type FileChange = 'new' | 'changed' | 'unchanged'
export type FileDiffStatus = 'changed' | 'new' | 'deleted'
export type AssetCategory = 'color' | 'diffuse' | 'roughness' | 'normal' | 'metalness' | 'height' | 'opacity' | 'orm' | 'ao' | 'assets'
export interface FileDiff {
name: string
status: 'changed' | 'new' | 'deleted'
status: FileDiffStatus
}
export interface RemoteFile {
@@ -29,3 +35,40 @@ export interface PreparedAssetSummary {
category?: AssetCategory
compressed: boolean
}
export interface StagingUploadResult {
stagingId: string
folderName: string
filesCount: number
}
export interface CheckUploadResult {
exists: boolean
diffs: FileDiff[]
warning?: string
}
export interface DriveUploadResult {
success: boolean
error?: string
}
export interface GitUploadResult {
success: boolean
filename?: string
warning?: string
error?: string
}
export interface PreparedGitAssetsResult {
filesToPush: PushFile[]
modelFilename: string
assetSummaries: PreparedAssetSummary[]
compressed: boolean
deliveryMode: GitModelMode
compressionError?: string
}
export interface PreparedStageAssetsResult extends PreparedGitAssetsResult {
folderName: string
}
+107 -100
View File
@@ -1,51 +1,92 @@
// ---------------------------------------------------------------------------
// Client-side API helpers for upload operations
// ---------------------------------------------------------------------------
import { getErrorMessage, isRecord } from './guards'
import type { FolderEntry } from './client-types'
import type { FileDiff } from './types'
import type {
CheckUploadResult,
DriveAction,
DriveUploadResult,
FileDiff,
GitModelMode,
GitUploadResult,
StagingUploadResult,
} from './types'
export interface CheckResult {
exists: boolean
diffs: FileDiff[]
interface CompressionWarningPayload {
compressionError?: unknown
}
export interface StageResult {
stagingId: string
folderName: string
filesCount: number
interface SuccessfulUploadData extends CompressionWarningPayload {
success: true
exists?: unknown
diffs?: unknown
stagingId?: unknown
folderName?: unknown
filesCount?: unknown
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
type UploadJsonBody =
| { stagingId: string }
| { stagingId: string; action: DriveAction }
function getApiError(data: unknown, fallback: string) {
return isRecord(data) && typeof data.error === 'string' ? data.error : fallback
}
function getClientRequestError(err: unknown, label: string) {
return `${label}: ${getErrorMessage(err)}`
}
function getCompressionWarning(data: CompressionWarningPayload) {
if (typeof data.compressionError !== 'string') return undefined
return `Compression GLB impossible. Le modele a ete prepare en GLTF separe. Detail : ${data.compressionError}`
}
function getUploadJsonHeaders(secret: string) {
return {
'Content-Type': 'application/json',
'x-upload-secret': secret.trim(),
}
}
async function postUploadJson(
endpoint: string,
secret: string,
body: UploadJsonBody,
signal?: AbortSignal,
) {
const res = await fetch(endpoint, {
method: 'POST',
headers: getUploadJsonHeaders(secret),
body: JSON.stringify(body),
signal,
})
const data: unknown = await res.json()
return { res, data }
}
function isSuccessfulUploadData(data: unknown): data is SuccessfulUploadData {
return isRecord(data) && data.success === true
}
function isAbortError(err: unknown) {
return err instanceof DOMException && err.name === 'AbortError'
}
function getNetworkUploadError(err: unknown, label: string) {
return isAbortError(err) ? 'Upload annule' : getClientRequestError(err, label)
}
function isFileDiff(value: unknown): value is FileDiff {
return isRecord(value)
&& typeof value.name === 'string'
&& (value.status === 'new' || value.status === 'changed' || value.status === 'deleted')
}
// ---------------------------------------------------------------------------
// Shared FormData builder
// ---------------------------------------------------------------------------
function buildUploadFormData(
folder: FolderEntry,
extra?: Record<string, string>,
): FormData {
function buildUploadFormData(folder: FolderEntry, gitModelMode: GitModelMode): FormData {
const formData = new FormData()
formData.append('folderName', folder.folderName)
if (extra) {
for (const [key, value] of Object.entries(extra)) {
formData.append(key, value)
}
}
formData.append('gitModelMode', gitModelMode)
formData.append('files', folder.modelFile)
formData.append('fileTypes', 'model')
@@ -60,51 +101,47 @@ function buildUploadFormData(
return formData
}
// ---------------------------------------------------------------------------
// Check folder diffs against remote (GitHub)
// ---------------------------------------------------------------------------
/**
* Check whether a folder already exists on the remote repo and compute diffs.
* Throws on auth/network errors so callers can surface them to the user.
*/
export async function checkFolderDiffs(
stagingId: string,
secret: string,
signal?: AbortSignal,
): Promise<CheckResult> {
const res = await fetch('/api/upload/check', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-upload-secret': secret.trim(),
},
body: JSON.stringify({ stagingId }),
signal,
})
): Promise<CheckUploadResult> {
const { res, data } = await postUploadJson('/api/upload/check', secret, { stagingId }, signal)
const data: unknown = await res.json()
// Surface auth/server errors to the caller
if (!res.ok) {
throw new Error(getApiError(data, `Erreur serveur (${res.status})`))
}
if (!isRecord(data) || data.success !== true || data.exists !== true) {
return { exists: false, diffs: [] }
if (!isSuccessfulUploadData(data)) {
throw new Error('Reponse serveur invalide')
}
const warning = getCompressionWarning(data)
if (data.exists !== true) {
return {
exists: false,
diffs: [],
warning,
}
}
const diffs = Array.isArray(data.diffs) ? data.diffs.filter(isFileDiff) : []
return { exists: true, diffs }
return {
exists: true,
diffs,
warning,
}
}
export async function stageUpload(
folder: FolderEntry,
gitModelMode: GitModelMode,
secret: string,
signal?: AbortSignal,
): Promise<StageResult> {
const formData = buildUploadFormData(folder)
): Promise<StagingUploadResult> {
const formData = buildUploadFormData(folder, gitModelMode)
const res = await fetch('/api/upload/stage', {
method: 'POST',
headers: { 'x-upload-secret': secret.trim() },
@@ -114,7 +151,7 @@ export async function stageUpload(
const data: unknown = await res.json()
if (!res.ok || !isRecord(data) || data.success !== true) {
if (!res.ok || !isSuccessfulUploadData(data)) {
throw new Error(getApiError(data, `Erreur serveur (${res.status})`))
}
@@ -129,77 +166,47 @@ export async function stageUpload(
}
}
// ---------------------------------------------------------------------------
// Upload original files to Nextcloud Drive
// ---------------------------------------------------------------------------
/** Upload original files to Nextcloud Drive. */
export async function uploadDrive(
stagingId: string,
secret: string,
action: 'new' | 'replace',
action: DriveAction,
signal?: AbortSignal,
): Promise<{ success: boolean; error?: string }> {
): Promise<DriveUploadResult> {
try {
const res = await fetch('/api/upload/drive', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-upload-secret': secret.trim(),
},
body: JSON.stringify({ stagingId, action }),
signal,
})
const data: unknown = await res.json()
if (!res.ok || !isRecord(data) || data.success !== true) {
const { res, data } = await postUploadJson('/api/upload/drive', secret, { stagingId, action }, signal)
if (!res.ok || !isSuccessfulUploadData(data)) {
return { success: false, error: getApiError(data, `Erreur serveur (${res.status})`) }
}
return { success: true }
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return { success: false, error: 'Upload annule' }
}
return { success: false, error: 'Erreur reseau (Drive)' }
return { success: false, error: getNetworkUploadError(err, 'Erreur Drive') }
}
}
// ---------------------------------------------------------------------------
// Upload files to GitHub
// ---------------------------------------------------------------------------
/** Upload files to GitHub. */
export async function uploadGit(
stagingId: string,
secret: string,
onProgress: (pct: number) => void,
signal?: AbortSignal,
): Promise<{ success: boolean; filename?: string; error?: string }> {
): Promise<GitUploadResult> {
onProgress(10)
try {
const res = await fetch('/api/upload/git', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-upload-secret': secret.trim(),
},
body: JSON.stringify({ stagingId }),
signal,
})
const { res, data } = await postUploadJson('/api/upload/git', secret, { stagingId }, signal)
onProgress(80)
const data: unknown = await res.json()
if (!res.ok || !isRecord(data) || data.success !== true) {
if (!res.ok || !isSuccessfulUploadData(data)) {
return { success: false, error: getApiError(data, `Erreur serveur (${res.status})`) }
}
onProgress(100)
return { success: true, filename: typeof data.folderName === 'string' ? data.folderName : undefined }
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return { success: false, error: 'Upload annule' }
return {
success: true,
filename: typeof data.folderName === 'string' ? data.folderName : undefined,
warning: getCompressionWarning(data),
}
return { success: false, error: 'Erreur reseau' }
} catch (err) {
return { success: false, error: getNetworkUploadError(err, 'Erreur GitHub') }
}
}
+31 -5
View File
@@ -1,4 +1,6 @@
export type DriveAction = 'new' | 'replace'
import { NextResponse } from 'next/server'
import { getErrorMessage, isRecord } from './guards'
import type { DriveAction } from './types'
interface StagingRequestBody {
stagingId: string
@@ -8,8 +10,19 @@ interface DriveRequestBody extends StagingRequestBody {
action: DriveAction
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
const UPLOAD_LOCK_ERROR = 'Un upload est deja en cours pour ce dossier. Patientez quelques secondes.'
export function uploadErrorResponse(error: unknown, status: number, fallback?: string) {
const message = getErrorMessage(error, fallback)
return NextResponse.json({ success: false, error: message }, { status })
}
export function uploadErrorMessageResponse(message: string, status: number) {
return NextResponse.json({ success: false, error: message }, { status })
}
export function uploadLockConflictResponse() {
return uploadErrorMessageResponse(UPLOAD_LOCK_ERROR, 409)
}
export function parseStagingRequestBody(value: unknown): StagingRequestBody {
@@ -20,9 +33,22 @@ export function parseStagingRequestBody(value: unknown): StagingRequestBody {
return { stagingId: value.stagingId }
}
export async function readStagingRequestBody(req: Request): Promise<StagingRequestBody> {
const body: unknown = await req.json()
return parseStagingRequestBody(body)
}
export function parseDriveRequestBody(value: unknown): DriveRequestBody {
const { stagingId } = parseStagingRequestBody(value)
const action = isRecord(value) && value.action === 'replace' ? 'replace' : 'new'
return { stagingId, action }
if (!isRecord(value) || (value.action !== 'new' && value.action !== 'replace')) {
throw new Error('Action Drive invalide')
}
return { stagingId, action: value.action }
}
export async function readDriveRequestBody(req: Request): Promise<DriveRequestBody> {
const body: unknown = await req.json()
return parseDriveRequestBody(body)
}
+93 -17
View File
@@ -3,9 +3,18 @@ import { dirname, join } from 'path'
import { mkdir, readdir, readFile, rm, writeFile } from 'fs/promises'
import { existsSync } from 'fs'
import { TMP_DIR } from '@/lib/constants'
import { isRecord } from '@/lib/guards'
import { getModelAssetPath } from '@/lib/model-paths'
import { prepareGitAssets } from '@/lib/prepare-git-assets'
import type { ParsedFile, PreparedAssetSummary, PushFile } from '@/lib/types'
import type {
AssetCategory,
GitModelMode,
ParsedFile,
PreparedAssetSummary,
PreparedStageAssetsResult,
PushFile,
StagingUploadResult,
} from '@/lib/types'
const STAGING_ROOT = join(TMP_DIR, 'staging')
const STAGING_TTL_MS = 60 * 60 * 1000
@@ -19,6 +28,7 @@ interface StagedOriginalFile {
interface StagedPreparedData {
modelFilename: string
compressed: boolean
deliveryMode?: GitModelMode
compressionError?: string
assetSummaries: PreparedAssetSummary[]
}
@@ -26,20 +36,12 @@ interface StagedPreparedData {
interface StagingManifest {
stagingId: string
folderName: string
gitModelMode: GitModelMode
createdAt: number
originals: StagedOriginalFile[]
prepared?: StagedPreparedData
}
interface PreparedStageAssetsResult {
folderName: string
filesToPush: PushFile[]
modelFilename: string
assetSummaries: PreparedAssetSummary[]
compressed: boolean
compressionError?: string
}
function getStageDir(stagingId: string) {
return join(STAGING_ROOT, stagingId)
}
@@ -56,6 +58,69 @@ function getManifestPath(stagingId: string) {
return join(getStageDir(stagingId), 'manifest.json')
}
function isGitModelMode(value: unknown): value is GitModelMode {
return value === 'draco-glb' || value === 'keep-gltf'
}
function isAssetCategory(value: unknown): value is AssetCategory {
return value === 'color'
|| value === 'diffuse'
|| value === 'roughness'
|| value === 'normal'
|| value === 'metalness'
|| value === 'height'
|| value === 'opacity'
|| value === 'orm'
|| value === 'ao'
|| value === 'assets'
}
function isPreparedAssetSummary(value: unknown): value is PreparedAssetSummary {
return isRecord(value)
&& typeof value.filename === 'string'
&& (value.kind === 'model' || value.kind === 'texture' || value.kind === 'asset')
&& (value.category === undefined || isAssetCategory(value.category))
&& typeof value.compressed === 'boolean'
}
function isStagedOriginalFile(value: unknown): value is StagedOriginalFile {
return isRecord(value)
&& typeof value.filename === 'string'
&& typeof value.size === 'number'
&& typeof value.isModel === 'boolean'
}
function isStagedPreparedData(value: unknown): value is StagedPreparedData {
return isRecord(value)
&& typeof value.modelFilename === 'string'
&& typeof value.compressed === 'boolean'
&& (value.deliveryMode === undefined || isGitModelMode(value.deliveryMode))
&& (value.compressionError === undefined || typeof value.compressionError === 'string')
&& Array.isArray(value.assetSummaries)
&& value.assetSummaries.every(isPreparedAssetSummary)
}
function isStagingManifest(value: unknown): value is StagingManifest {
return isRecord(value)
&& typeof value.stagingId === 'string'
&& typeof value.folderName === 'string'
&& isGitModelMode(value.gitModelMode)
&& typeof value.createdAt === 'number'
&& Array.isArray(value.originals)
&& value.originals.every(isStagedOriginalFile)
&& (value.prepared === undefined || isStagedPreparedData(value.prepared))
}
function parseStagingManifest(content: string) {
const parsed: unknown = JSON.parse(content)
if (!isStagingManifest(parsed)) {
throw new Error('Manifest de staging invalide')
}
return parsed
}
async function ensureParentDir(filePath: string) {
await mkdir(dirname(filePath), { recursive: true })
}
@@ -65,7 +130,7 @@ async function writeManifest(manifest: StagingManifest) {
await writeFile(getManifestPath(manifest.stagingId), JSON.stringify(manifest, null, 2), 'utf-8')
}
export async function cleanupExpiredStagingUploads() {
async function cleanupExpiredStagingUploads() {
if (!existsSync(STAGING_ROOT)) return
const entries = await readdir(STAGING_ROOT, { withFileTypes: true })
@@ -87,7 +152,11 @@ export async function cleanupExpiredStagingUploads() {
}
}
export async function createStagingUpload(folderName: string, parsedFiles: ParsedFile[]) {
export async function createStagingUpload(
folderName: string,
parsedFiles: ParsedFile[],
gitModelMode: GitModelMode,
): Promise<StagingUploadResult> {
await cleanupExpiredStagingUploads()
const stagingId = randomUUID()
@@ -106,6 +175,7 @@ export async function createStagingUpload(folderName: string, parsedFiles: Parse
const manifest: StagingManifest = {
stagingId,
folderName,
gitModelMode,
createdAt: Date.now(),
originals,
}
@@ -122,7 +192,7 @@ export async function createStagingUpload(folderName: string, parsedFiles: Parse
export async function readStagedManifest(stagingId: string): Promise<StagingManifest> {
const manifestPath = getManifestPath(stagingId)
const content = await readFile(manifestPath, 'utf-8')
return JSON.parse(content) as StagingManifest
return parseStagingManifest(content)
}
async function readOriginalParsedFiles(stagingId: string, manifest: StagingManifest): Promise<ParsedFile[]> {
@@ -137,11 +207,11 @@ async function readOriginalParsedFiles(stagingId: string, manifest: StagingManif
)
}
async function buildPreparedPushFiles(stagingId: string, manifest: StagingManifest): Promise<PushFile[]> {
async function buildPreparedPushFiles(stagingId: string, manifest: StagingManifest, prepared: StagedPreparedData): Promise<PushFile[]> {
const preparedDir = getPreparedDir(stagingId)
return Promise.all(
manifest.originals.map(async (file) => {
prepared.assetSummaries.map(async (file) => {
const buffer = await readFile(join(preparedDir, file.filename))
return {
path: getModelAssetPath(manifest.folderName, file.filename),
@@ -156,7 +226,11 @@ export async function ensurePreparedStagingAssets(stagingId: string): Promise<Pr
if (!manifest.prepared) {
const parsedFiles = await readOriginalParsedFiles(stagingId, manifest)
const prepared = await prepareGitAssets({ folderName: manifest.folderName, parsedFiles })
const prepared = await prepareGitAssets({
folderName: manifest.folderName,
parsedFiles,
gitModelMode: manifest.gitModelMode,
})
const preparedDir = getPreparedDir(stagingId)
await mkdir(preparedDir, { recursive: true })
@@ -171,6 +245,7 @@ export async function ensurePreparedStagingAssets(stagingId: string): Promise<Pr
manifest.prepared = {
modelFilename: prepared.modelFilename,
compressed: prepared.compressed,
deliveryMode: prepared.deliveryMode,
compressionError: prepared.compressionError,
assetSummaries: prepared.assetSummaries,
}
@@ -180,10 +255,11 @@ export async function ensurePreparedStagingAssets(stagingId: string): Promise<Pr
return {
folderName: manifest.folderName,
filesToPush: await buildPreparedPushFiles(stagingId, manifest),
filesToPush: await buildPreparedPushFiles(stagingId, manifest, manifest.prepared),
modelFilename: manifest.prepared.modelFilename,
assetSummaries: manifest.prepared.assetSummaries,
compressed: manifest.prepared.compressed,
deliveryMode: manifest.prepared.deliveryMode ?? manifest.gitModelMode,
compressionError: manifest.prepared.compressionError,
}
}
+26 -51
View File
@@ -1,28 +1,30 @@
// ---------------------------------------------------------------------------
// Client-side folder validation
// ---------------------------------------------------------------------------
import { ASSET_EXTENSIONS, TEXTURE_EXTENSIONS } from '@/lib/constants'
import { formatAssetFamilies, getAssetFamily, getForbiddenAssetFamilyAlias } from '@/lib/asset-naming'
import { getTextureNamingError } from '@/lib/asset-naming'
import { getErrorMessage, isRecord } from '@/lib/guards'
import type { TextureFile } from '@/lib/client-types'
const SUPPORT_FILE_EXT_ARRAY = [...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS]
interface GltfBufferReference {
uri?: unknown
uri?: string
}
interface GltfJson {
buffers?: GltfBufferReference[]
}
/** Discriminated union: either valid (with model) or invalid (with errors). */
export type ValidationResult =
type ValidationResult =
| { ok: true; model: File; textures: TextureFile[]; warnings: string[] }
| { ok: false; errors: string[] }
function isGltfBufferReference(value: unknown): value is GltfBufferReference {
return isRecord(value) && (value.uri === undefined || typeof value.uri === 'string')
}
function isGltfJson(value: unknown): value is GltfJson {
return typeof value === 'object' && value !== null
if (!isRecord(value)) return false
if (value.buffers === undefined) return true
return Array.isArray(value.buffers) && value.buffers.every(isGltfBufferReference)
}
function getReferencedBufferNames(gltf: GltfJson) {
@@ -38,44 +40,6 @@ function getFileExtension(filename: string) {
return filename.slice(filename.lastIndexOf('.')).toLowerCase()
}
function getFileStem(filename: string) {
return filename.replace(/\.[^.]+$/, '')
}
function getTextureNamingError(file: File) {
const stem = getFileStem(file.name)
const [prefix, ...targetParts] = stem.split('_')
const family = getAssetFamily(prefix)
const extension = file.name.split('.').pop()
if (family && targetParts.every(Boolean)) return null
const aliasSuggestion = getForbiddenAssetFamilyAlias(prefix)
if (aliasSuggestion && targetParts.every(Boolean)) {
const target = targetParts.join('_')
return `Convention invalide : ${file.name}. Utilisez ${aliasSuggestion}_${target}.${extension} pour cibler "${target}", ou ${aliasSuggestion}.${extension} pour tout le modele.`
}
const reversedParts = stem.split('_')
const reversedFamily = reversedParts.length > 1 ? getAssetFamily(reversedParts[reversedParts.length - 1]) : undefined
const reversedAliasSuggestion = reversedParts.length > 1
? getForbiddenAssetFamilyAlias(reversedParts[reversedParts.length - 1])
: undefined
if (reversedFamily) {
const target = reversedParts.slice(0, -1).join('_')
return `Convention invalide : ${file.name}. Utilisez ${reversedFamily}_${target}.${extension} pour cibler "${target}", ou ${reversedFamily}.${extension} pour tout le modele.`
}
if (reversedAliasSuggestion) {
const target = reversedParts.slice(0, -1).join('_')
return `Convention invalide : ${file.name}. Utilisez ${reversedAliasSuggestion}_${target}.${extension} pour cibler "${target}", ou ${reversedAliasSuggestion}.${extension} pour tout le modele.`
}
return `Asset inconnu : ${file.name}. Familles autorisees : ${formatAssetFamilies()}. Utilisez asset.png pour tout le modele ou asset_objet.png pour cibler un objet.`
}
async function getGltfWarnings(model: File, supportFiles: File[]) {
const warnings: string[] = []
let parsed: unknown
@@ -83,10 +47,12 @@ async function getGltfWarnings(model: File, supportFiles: File[]) {
try {
parsed = JSON.parse(await model.text())
} catch {
return warnings
throw new Error('model.gltf contient un JSON invalide')
}
if (!isGltfJson(parsed)) return warnings
if (!isGltfJson(parsed)) {
throw new Error('model.gltf a une structure invalide')
}
const supportFilenames = new Set(supportFiles.map((file) => file.name.toLowerCase()))
const binFiles = supportFiles.filter((file) => file.name.toLowerCase().endsWith('.bin'))
@@ -131,7 +97,7 @@ export async function validateFolder(files: File[]): Promise<ValidationResult> {
const textureNamingErrors = supportFiles
.filter((file) => TEXTURE_EXTENSIONS.has(getFileExtension(file.name)))
.map(getTextureNamingError)
.map((file) => getTextureNamingError(file.name))
.filter((error): error is string => Boolean(error))
errors.push(...textureNamingErrors)
@@ -144,7 +110,16 @@ export async function validateFolder(files: File[]): Promise<ValidationResult> {
return { ok: false, errors }
}
const warnings = await getGltfWarnings(modelFiles[0], supportFiles)
let warnings: string[] = []
try {
warnings = await getGltfWarnings(modelFiles[0], supportFiles)
} catch (err) {
errors.push(getErrorMessage(err, 'model.gltf invalide'))
}
if (errors.length > 0) {
return { ok: false, errors }
}
return { ok: true, model: modelFiles[0], textures, warnings }
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "upload-gltf",
"version": "0.1.5",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "upload-gltf",
"version": "0.1.5",
"version": "1.0.0",
"dependencies": {
"@octokit/rest": "^22.0.1",
"@react-three/drei": "^10.7.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "upload-gltf",
"version": "0.1.5",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
+422
View File
@@ -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_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_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()