fix: support gltf uploads with local preview

This commit is contained in:
Tom Boullay
2026-04-27 11:07:16 +02:00
parent 078e687e86
commit 4c3a687ff8
19 changed files with 136 additions and 98 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
*.glb filter=lfs diff=lfs merge=lfs -text
*.gltf 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 *.png filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text *.jpg filter=lfs diff=lfs merge=lfs -text
*.jpeg filter=lfs diff=lfs merge=lfs -text *.jpeg filter=lfs diff=lfs merge=lfs -text
+26 -24
View File
@@ -1,9 +1,9 @@
# upload-GLTF # upload-GLTF
A secure web interface for uploading `model.glb` and its associated textures with two outputs: 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. - **Nextcloud Drive** — Archives the original files with automatic versioning (VF/V1/V2...), so artists always have a history of past versions.
- **GitHub** — Delivers compressed models (Draco via Blender) and compressed textures to the dev team's repository, ready for integration. - **GitHub** — Delivers GLTF assets and compressed textures to the dev team's repository, ready for integration.
Built for La Fabrik Durable. Built for La Fabrik Durable.
@@ -14,7 +14,7 @@ Built for La Fabrik Durable.
- **Tailwind CSS** for styling - **Tailwind CSS** for styling
- **Octokit** for pushing via the GitHub API - **Octokit** for pushing via the GitHub API
- **Nextcloud WebDAV** for Drive archiving with automatic versioning - **Nextcloud WebDAV** for Drive archiving with automatic versioning
- **Blender** (headless) for Draco mesh compression - **Sharp** for server-side texture compression
- **Coolify** (Docker) for hosting - **Coolify** (Docker) for hosting
## Installation ## Installation
@@ -34,7 +34,6 @@ UPLOAD_SECRET_KEY=your-secret-key-here
GITHUB_TOKEN=ghp_your-github-personal-access-token GITHUB_TOKEN=ghp_your-github-personal-access-token
GIT_BRANCH=main GIT_BRANCH=main
GIT_REPO_URL=https://github.com/your-org/your-repo.git GIT_REPO_URL=https://github.com/your-org/your-repo.git
BLENDER_PATH=/Applications/Blender.app/Contents/MacOS/Blender
# Nextcloud Drive (public share WebDAV) # Nextcloud Drive (public share WebDAV)
NEXTCLOUD_URL=https://cloud.example.com NEXTCLOUD_URL=https://cloud.example.com
@@ -49,7 +48,6 @@ NEXTCLOUD_BASE_PATH=Models
| `GITHUB_TOKEN` | GitHub Personal Access Token (fine-grained, `Contents: Read and write`) | Yes | | `GITHUB_TOKEN` | GitHub Personal Access Token (fine-grained, `Contents: Read and write`) | Yes |
| `GIT_BRANCH` | Target branch (default: main) | No | | `GIT_BRANCH` | Target branch (default: main) | No |
| `GIT_REPO_URL` | Target GitHub repository URL | Yes | | `GIT_REPO_URL` | Target GitHub repository URL | Yes |
| `BLENDER_PATH` | Path to Blender binary (default: `blender`) | No |
| `NEXTCLOUD_URL` | Nextcloud instance 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_TOKEN` | Public share token (the part after `/s/` in the share link) | Yes |
| `NEXTCLOUD_SHARE_PASSWORD` | Public share password (empty if none) | No | | `NEXTCLOUD_SHARE_PASSWORD` | Public share password (empty if none) | No |
@@ -67,9 +65,9 @@ npm run dev
Access the app at `http://localhost:3000` Access the app at `http://localhost:3000`
> **Note:** Draco compression requires Blender installed locally. On macOS, Blender is typically at `/Applications/Blender.app/Contents/MacOS/Blender`. Set `BLENDER_PATH` in `.env.local` accordingly. If Blender is not available, models are pushed to GitHub without compression. > **Note:** The current upload contract accepts `model.gltf` and preserves it as GLTF. `.glb` uploads are rejected by validation.
> >
> Local 3D preview is currently supported for `.glb` files only. > Local 3D preview supports `model.gltf` folders by resolving dropped companion files such as `model.bin` and textures through local object URLs.
### Production (Coolify / Docker) ### Production (Coolify / Docker)
@@ -84,16 +82,17 @@ docker run -p 3000:3000 \
upload-gltf upload-gltf
``` ```
The Docker image includes Blender headless (installed once at build time). On startup, the entrypoint checks if Blender is available and logs its version. No extra configuration is needed in production — `BLENDER_PATH` defaults to `blender` which is in the container's PATH. The Docker image runs the Next.js app and server-side asset preparation in a single container.
## How it works ## How it works
1. The user enters their access key 1. The user enters their access key
2. They select a folder containing: 2. They select a folder containing:
- `model.glb` (**required**) - `model.gltf` (**required**)
- Any associated binary buffer (`.bin`, for example `model.bin`)
- Any associated textures (`.png/.jpg/.jpeg/.webp`) - Any associated textures (`.png/.jpg/.jpeg/.webp`)
3. The `.glb` model is displayed in a local 3D preview 3. The folder is validated locally. `.glb` files are not accepted.
5. On clicking "Envoyer": 4. On clicking "Envoyer":
- The app uploads the folder once to a temporary server-side staging area - 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 prepares the final Git payload from this staging area
- The app checks the remote Git repo for existing files and computes diffs - The app checks the remote Git repo for existing files and computes diffs
@@ -103,8 +102,8 @@ The Docker image includes Blender headless (installed once at build time). On st
### Upload flow: Drive first, then Git ### Upload flow: Drive first, then Git
6. **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. 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.
7. **Git upload (delivery to devs)** — The prepared Git payload is reused from staging: models are compressed with Blender Draco, 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: `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.
### Drive versioning (Nextcloud WebDAV) ### Drive versioning (Nextcloud WebDAV)
@@ -114,7 +113,8 @@ The Drive uses a `VF` (version finale) / `Vx` (archived versions) structure:
Models/ Models/
VF/ ← latest version VF/ ← latest version
coffeetest/ coffeetest/
model.glb model.gltf
model.bin
color.jpg color.jpg
V1/ ← first archive V1/ ← first archive
coffeetest/ coffeetest/
@@ -143,7 +143,7 @@ All changes are pushed in a **single commit** with a grouped formatted message:
update: upload-gltf add a new model -> my-model update: upload-gltf add a new model -> my-model
📦 Model 📦 Model
✅ model.glb (compressed) ✅ model.gltf
🎨 Textures (color) 🎨 Textures (color)
✅ color_porte.jpg (compressed) ✅ color_porte.jpg (compressed)
@@ -151,6 +151,7 @@ update: upload-gltf add a new model -> my-model
✅ roughness_tuyaux.png (compressed) ✅ roughness_tuyaux.png (compressed)
🧩 Assets 🧩 Assets
✅ model.bin
✅ opacity_fenetre.png (compressed) ✅ opacity_fenetre.png (compressed)
``` ```
@@ -159,7 +160,7 @@ update: upload-gltf add a new model -> my-model
update: upload-gltf update -> coffeetest update: upload-gltf update -> coffeetest
📦 Model 📦 Model
↔️ model.glb (compressed) ↔️ model.gltf
🎨 Textures (color) 🎨 Textures (color)
🔄 color_tuyaux.jpg (compressed) 🔄 color_tuyaux.jpg (compressed)
@@ -177,8 +178,8 @@ Sections currently used:
Symbols: `✅` new — `🔄` modified — `↔️` unchanged (model always re-pushed) — `❌` deleted Symbols: `✅` new — `🔄` modified — `↔️` unchanged (model always re-pushed) — `❌` deleted
8. Orphan files (present on remote but not in the new upload) are deleted in the same commit 7. Orphan files (present on remote but not in the new upload) are deleted in the same commit
9. If Blender is unavailable, the original model is pushed as-is (graceful fallback) 8. `model.gltf` is pushed as-is so companion files like `model.bin` remain valid
Uploaded models are pushed to `public/models/<folderName>/` in the target repo. Uploaded models are pushed to `public/models/<folderName>/` in the target repo.
@@ -186,7 +187,7 @@ Uploaded models are pushed to `public/models/<folderName>/` in the target repo.
- Large uploads are faster than before because the folder is staged only once, but the Drive upload remains sequential. - 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. - Git LFS uploads are still sequential.
- The current upload contract still expects a single `model.glb` file and a flat texture set. - The current upload contract expects a single `model.gltf` file plus optional flat support files (`.bin`, `.png`, `.jpg`, `.jpeg`, `.webp`).
## Project Structure ## Project Structure
@@ -234,22 +235,23 @@ lib/
├── upload-staging.ts # Temporary server-side staging and prepared asset reuse ├── upload-staging.ts # Temporary server-side staging and prepared asset reuse
├── upload-lock.ts # Lightweight in-memory per-folder upload lock ├── upload-lock.ts # Lightweight in-memory per-folder upload lock
├── asset-classification.ts # Group assets by family for commit messages ├── asset-classification.ts # Group assets by family for commit messages
├── blender.ts # Blender Draco compression ├── blender.ts # Legacy Blender compression helper
├── commit-message.ts # Commit message builder ├── commit-message.ts # Commit message builder
├── parse-upload.ts # FormData parser + validation ├── parse-upload.ts # FormData parser + validation
├── validate-folder.ts # Client-side folder validation (discriminated union) ├── validate-folder.ts # Client-side folder validation (discriminated union)
└── format-bytes.ts # Byte formatting utility └── format-bytes.ts # Byte formatting utility
scripts/ scripts/
└── compress.py # Blender Draco compression script └── compress.py # Legacy Blender compression script
Dockerfile # Multi-stage build: Node 20 slim + Blender headless + tini Dockerfile # Multi-stage build: Node 20 slim + tini
docker-entrypoint.sh # Startup: Blender check + launch docker-entrypoint.sh # Startup check + launch
``` ```
## Supported Formats ## Supported Formats
| Type | Extensions | | Type | Extensions |
|------|------------| |------|------------|
| 3D Models | `.glb` | | 3D Models | `.gltf` |
| Binary buffers | `.bin` |
| Textures | `.png`, `.jpg`, `.jpeg`, `.webp` | | Textures | `.png`, `.jpg`, `.jpeg`, `.webp` |
## License ## License
+1 -1
View File
@@ -15,7 +15,7 @@ export const dynamic = 'force-dynamic'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// POST /api/upload/drive // POST /api/upload/drive
// //
// Upload **original** files (no Blender compression) to Nextcloud Drive. // Upload **original** files to Nextcloud Drive.
// //
// JSON body: // JSON body:
// - stagingId // - stagingId
+2 -2
View File
@@ -11,7 +11,7 @@ export const dynamic = 'force-dynamic'
/** /**
* POST /api/upload/git * POST /api/upload/git
* Upload files, compress with Blender, and push to GitHub via Octokit. * Upload prepared files and push to GitHub via Octokit.
*/ */
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
// --- Auth --- // --- Auth ---
@@ -42,7 +42,7 @@ export async function POST(req: NextRequest) {
} }
try { try {
// --- Process files (compress model + textures for Git) --- // --- Process files (preserve model + buffers, compress textures for Git) ---
const { const {
filesToPush, filesToPush,
modelFilename, modelFilename,
+1 -1
View File
@@ -16,7 +16,7 @@ const jetbrainsMono = JetBrains_Mono({
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Upload GLTF', title: 'Upload GLTF',
description: 'Interface de depot securise pour fichiers 3D (.glb, .gltf) avec versionnement automatique sur GitHub', description: 'Interface de depot securise pour fichiers 3D (.gltf) avec versionnement automatique sur GitHub',
} }
export default function RootLayout({ export default function RootLayout({
+2 -2
View File
@@ -16,9 +16,9 @@ export default function Home() {
<UploadZone /> <UploadZone />
<footer className="mt-3 text-gray-500 text-xs text-center"> <footer className="mt-3 text-gray-500 text-xs text-center">
Modeles : <span className="font-mono text-gray-400">.glb</span> Modeles : <span className="font-mono text-gray-400">.gltf</span>
<span className="mx-2">·</span> <span className="mx-2">·</span>
Textures : <span className="font-mono text-gray-400">.png · .jpg · .webp</span> Assets : <span className="font-mono text-gray-400">.bin · .png · .jpg · .webp</span>
</footer> </footer>
</main> </main>
) )
+6 -5
View File
@@ -4,13 +4,14 @@ import { useEffect, useState } from 'react'
interface ModelViewerProps { interface ModelViewerProps {
url: string url: string
assetUrls: Record<string, string>
filename: string filename: string
size: string size: string
} }
export default function ModelViewer({ url, filename, size }: ModelViewerProps) { export default function ModelViewer({ url, assetUrls, filename, size }: ModelViewerProps) {
const canPreview = filename.toLowerCase().endsWith('.glb') const canPreview = filename.toLowerCase().endsWith('.gltf')
const [Scene, setScene] = useState<React.ComponentType<{ url: string }> | null>(null) const [Scene, setScene] = useState<React.ComponentType<{ url: string; assetUrls: Record<string, string> }> | null>(null)
useEffect(() => { useEffect(() => {
if (!canPreview) return if (!canPreview) return
@@ -28,7 +29,7 @@ export default function ModelViewer({ url, filename, size }: ModelViewerProps) {
return ( return (
<div className="w-full h-[450px] bg-black-800 border border-white/20 rounded-xl overflow-hidden flex items-center justify-center"> <div className="w-full h-[450px] bg-black-800 border border-white/20 rounded-xl overflow-hidden flex items-center justify-center">
<p className="text-sm text-gray-400 px-6 text-center"> <p className="text-sm text-gray-400 px-6 text-center">
La preview 3D locale est disponible uniquement pour les fichiers <span className="font-mono">.glb</span>. La preview 3D locale n&apos;est pas disponible pour les dossiers <span className="font-mono">model.gltf</span> avec fichiers associes.
</p> </p>
</div> </div>
) )
@@ -52,7 +53,7 @@ export default function ModelViewer({ url, filename, size }: ModelViewerProps) {
{size} {size}
</span> </span>
</div> </div>
<Scene url={url} /> <Scene url={url} assetUrls={assetUrls} />
</div> </div>
) )
} }
+21 -5
View File
@@ -2,19 +2,35 @@
import { Suspense } from 'react' import { Suspense } from 'react'
import { Canvas } from '@react-three/fiber' import { Canvas } from '@react-three/fiber'
import { Stage, OrbitControls, useGLTF } from '@react-three/drei' import { Stage, OrbitControls } from '@react-three/drei'
import { useLoader } from '@react-three/fiber'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
function resolveAssetUrl(requestedUrl: string, assetUrls: Record<string, string>) {
if (requestedUrl.startsWith('blob:') || requestedUrl.startsWith('data:')) {
return requestedUrl
}
const cleanUrl = decodeURIComponent(requestedUrl.split(/[?#]/)[0] || '')
const filename = cleanUrl.split(/[\\/]/).pop()?.toLowerCase()
return filename ? assetUrls[filename] || requestedUrl : requestedUrl
}
function Model({ url, assetUrls }: { url: string; assetUrls: Record<string, string> }) {
const { scene } = useLoader(GLTFLoader, url, (loader) => {
loader.manager.setURLModifier((requestedUrl) => resolveAssetUrl(requestedUrl, assetUrls))
})
function Model({ url }: { url: string }) {
const { scene } = useGLTF(url)
return <primitive object={scene} /> return <primitive object={scene} />
} }
export default function SceneViewer({ url }: { url: string }) { export default function SceneViewer({ url, assetUrls }: { url: string; assetUrls: Record<string, string> }) {
return ( return (
<Canvas dpr={[1, 2]} camera={{ fov: 50 }}> <Canvas dpr={[1, 2]} camera={{ fov: 50 }}>
<Suspense fallback={null}> <Suspense fallback={null}>
<Stage environment="city" intensity={0.6} adjustCamera={1.2}> <Stage environment="city" intensity={0.6} adjustCamera={1.2}>
<Model url={url} /> <Model url={url} assetUrls={assetUrls} />
</Stage> </Stage>
</Suspense> </Suspense>
<OrbitControls makeDefault autoRotate autoRotateSpeed={0.5} /> <OrbitControls makeDefault autoRotate autoRotateSpeed={0.5} />
+11 -4
View File
@@ -60,6 +60,12 @@ export default function UploadZone() {
}) })
const handleFolderSelected = (entry: FolderEntry) => { const handleFolderSelected = (entry: FolderEntry) => {
entries.forEach((current) => {
const urls = new Set<string>()
if (current.modelUrl) urls.add(current.modelUrl)
Object.values(current.assetUrls || {}).forEach((url) => urls.add(url))
urls.forEach((url) => URL.revokeObjectURL(url))
})
setGlobalError(null) setGlobalError(null)
setEntries([entry]) setEntries([entry])
} }
@@ -78,12 +84,13 @@ export default function UploadZone() {
{entries.length === 0 && ( {entries.length === 0 && (
<p className="rounded-2xl border border-white/20 bg-black-800 px-4 py-3 text-xs text-gray-400 leading-relaxed text-center mb-3"> <p className="rounded-2xl border border-white/20 bg-black-800 px-4 py-3 text-xs text-gray-400 leading-relaxed text-center mb-3">
Deposez un dossier complet contenant votre modele 3D nomme Deposez un dossier complet contenant votre modele 3D nomme
{' '}<span className="font-mono text-gray-200">model.glb</span> {' '}<span className="font-mono text-gray-200">model.gltf</span>
{' '}ainsi que toutes les textures necessaires. {' '}ainsi que toutes les textures et fichiers binaires necessaires.
{' '}Les textures peuvent etre en {' '}Les fichiers associes peuvent etre en
{' '}<span className="font-mono text-gray-200">.png</span>, {' '}<span className="font-mono text-gray-200">.png</span>,
{' '}<span className="font-mono text-gray-200">.jpg</span> {' '}<span className="font-mono text-gray-200">.jpg</span>
{' '}ou <span className="font-mono text-gray-200">.webp</span>. {' '}<span className="font-mono text-gray-200">.webp</span>
{' '}ou <span className="font-mono text-gray-200">.bin</span>.
{' '}Utilisez un nom simple si la texture s&apos;applique au modele entier, et un nom detaille si elle correspond a une partie precise du modele, {' '}Utilisez un nom simple si la texture s&apos;applique au modele entier, et un nom detaille si elle correspond a une partie precise du modele,
{' '}par exemple <span className="font-mono text-gray-200">color_porte.jpg</span>, {' '}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">roughness_tuyaux.png</span>,
+1
View File
@@ -102,6 +102,7 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F
> >
<ModelViewer <ModelViewer
url={entry.modelUrl} url={entry.modelUrl}
assetUrls={entry.assetUrls || {}}
filename={entry.modelFile.name} filename={entry.modelFile.name}
size={formatBytes(entry.modelFile.size)} size={formatBytes(entry.modelFile.size)}
/> />
+20 -3
View File
@@ -3,6 +3,19 @@ import type { FolderEntry } from '@/lib/client-types'
import { validateFolder } from '@/lib/validate-folder' import { validateFolder } from '@/lib/validate-folder'
import { FolderIcon } from '@/components/ui/icons' import { FolderIcon } from '@/components/ui/icons'
function buildAssetUrls(model: File, supportFiles: File[]) {
const assetUrls: Record<string, string> = {}
const modelUrl = URL.createObjectURL(model)
assetUrls[model.name.toLowerCase()] = modelUrl
for (const file of supportFiles) {
assetUrls[file.name.toLowerCase()] = URL.createObjectURL(file)
}
return { modelUrl, assetUrls }
}
function readDroppedFile(entry: FileSystemFileEntry) { function readDroppedFile(entry: FileSystemFileEntry) {
return new Promise<File>((resolve, reject) => { return new Promise<File>((resolve, reject) => {
entry.file(resolve, reject) entry.file(resolve, reject)
@@ -60,6 +73,11 @@ export default function FolderDropzone({
return return
} }
const { modelUrl, assetUrls } = buildAssetUrls(
validation.model,
validation.textures.map((texture) => texture.file),
)
const entry: FolderEntry = { const entry: FolderEntry = {
folderName, folderName,
modelFile: validation.model, modelFile: validation.model,
@@ -67,9 +85,8 @@ export default function FolderDropzone({
status: 'pending', status: 'pending',
progress: 0, progress: 0,
warnings: validation.warnings, warnings: validation.warnings,
modelUrl: validation.model.name.toLowerCase() === 'model.glb' modelUrl,
? URL.createObjectURL(validation.model) assetUrls,
: undefined,
viewerOpen: true, viewerOpen: true,
} }
+9 -2
View File
@@ -3,6 +3,13 @@
import { useState, useCallback } from 'react' import { useState, useCallback } from 'react'
import type { FolderEntry } from '@/lib/client-types' import type { FolderEntry } from '@/lib/client-types'
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))
urls.forEach((url) => URL.revokeObjectURL(url))
}
export function useFolderEntries() { export function useFolderEntries() {
const [entries, setEntries] = useState<FolderEntry[]>([]) const [entries, setEntries] = useState<FolderEntry[]>([])
@@ -13,7 +20,7 @@ export function useFolderEntries() {
const removeEntry = useCallback((index: number) => { const removeEntry = useCallback((index: number) => {
setEntries((prev) => { setEntries((prev) => {
const entry = prev[index] const entry = prev[index]
if (entry?.modelUrl) URL.revokeObjectURL(entry.modelUrl) if (entry) revokeEntryUrls(entry)
return prev.filter((_, i) => i !== index) return prev.filter((_, i) => i !== index)
}) })
}, []) }, [])
@@ -21,7 +28,7 @@ export function useFolderEntries() {
const resetEntries = useCallback(() => { const resetEntries = useCallback(() => {
setEntries((prev) => { setEntries((prev) => {
prev.forEach((f) => { prev.forEach((f) => {
if (f.modelUrl) URL.revokeObjectURL(f.modelUrl) revokeEntryUrls(f)
}) })
return [] return []
}) })
+1
View File
@@ -20,6 +20,7 @@ export interface FolderEntry {
error?: string error?: string
filename?: string filename?: string
modelUrl?: string modelUrl?: string
assetUrls?: Record<string, string>
viewerOpen?: boolean viewerOpen?: boolean
warnings: string[] warnings: string[]
driveStatus?: DriveStatus driveStatus?: DriveStatus
+4 -3
View File
@@ -2,12 +2,13 @@
// Shared constants — used by both client and server // Shared constants — used by both client and server
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const MODEL_EXTENSIONS = new Set(['.glb', '.gltf']) export const MODEL_EXTENSIONS = new Set(['.gltf'])
export const TEXTURE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']) export const TEXTURE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp'])
export const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS]) 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) */ /** Extensions tracked by Git LFS (must match .gitattributes) */
export const LFS_EXTENSIONS = new Set(['.glb', '.gltf', '.png', '.jpg', '.jpeg', '.webp']) export const LFS_EXTENSIONS = new Set(['.gltf', '.bin', '.png', '.jpg', '.jpeg', '.webp'])
export const TMP_DIR = '/tmp/assets' export const TMP_DIR = '/tmp/assets'
+2 -2
View File
@@ -26,9 +26,9 @@ export interface DiffResult {
* the remote file map. * the remote file map.
* *
* Rules: * Rules:
* - Models: always re-pushed (compression makes size comparison unreliable), * - Models: always re-pushed,
* but marked as 'unchanged' in the commit message when the folder already * but marked as 'unchanged' in the commit message when the folder already
* exists (we can't know if the model really changed after Blender). * exists (we keep the current behavior of always delivering the model file).
* - Textures: compared by size (not compressed, reliable). * - Textures: compared by size (not compressed, reliable).
* - Orphan remote files: classified as deletions. * - Orphan remote files: classified as deletions.
*/ */
+15
View File
@@ -49,6 +49,7 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
} }
const parsed: ParsedFile[] = [] const parsed: ParsedFile[] = []
let modelCount = 0
for (let i = 0; i < fileEntries.length; i++) { for (let i = 0; i < fileEntries.length; i++) {
const file = fileEntries[i] const file = fileEntries[i]
@@ -79,10 +80,24 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
} }
const isModel = MODEL_EXTENSIONS.has(ext) const isModel = MODEL_EXTENSIONS.has(ext)
if (isModel) {
if (filename.toLowerCase() !== 'model.gltf') {
throw new Error('Le modele doit etre nomme model.gltf')
}
modelCount += 1
}
const buffer = Buffer.from(await file.arrayBuffer()) const buffer = Buffer.from(await file.arrayBuffer())
parsed.push({ filename, buffer, isModel }) parsed.push({ filename, buffer, isModel })
} }
if (modelCount === 0) {
throw new Error('model.gltf manquant (obligatoire)')
}
if (modelCount > 1) {
throw new Error('Un seul fichier model.gltf est autorise')
}
return { folderName: safeFolderName, files: parsed, extra } return { folderName: safeFolderName, files: parsed, extra }
} }
+1 -31
View File
@@ -1,8 +1,3 @@
import { join } from 'path'
import { existsSync } from 'fs'
import { mkdir, writeFile, readFile, unlink, rm } from 'fs/promises'
import { TMP_DIR } from '@/lib/constants'
import { compressWithBlender } from '@/lib/blender'
import { compressTextureBuffer } from '@/lib/texture-compression' import { compressTextureBuffer } from '@/lib/texture-compression'
import { classifyAssetCategory } from '@/lib/asset-classification' import { classifyAssetCategory } from '@/lib/asset-classification'
import type { ParsedFile, PreparedAssetSummary } from '@/lib/types' import type { ParsedFile, PreparedAssetSummary } from '@/lib/types'
@@ -40,36 +35,11 @@ export async function prepareGitAssets({
if (pf.isModel) { if (pf.isModel) {
modelFilename = pf.filename modelFilename = pf.filename
let modelCompressed = false
const tmpFolder = join(TMP_DIR, folderName)
await mkdir(tmpFolder, { recursive: true })
const tmpFilePath = join(tmpFolder, pf.filename)
await writeFile(tmpFilePath, pf.buffer)
const stem = pf.filename.replace(/\.[^.]+$/, '')
const compressedPath = join(tmpFolder, `${stem}_compressed.glb`)
try {
const result = await compressWithBlender(tmpFilePath, compressedPath)
if (result.success && existsSync(compressedPath)) {
content = await readFile(compressedPath)
compressed = true
modelCompressed = true
await unlink(compressedPath).catch(() => {})
} else {
compressionError = result.error
}
} finally {
await unlink(tmpFilePath).catch(() => {})
await rm(tmpFolder, { recursive: true, force: true }).catch(() => {})
}
assetSummaries.push({ assetSummaries.push({
filename: pf.filename, filename: pf.filename,
kind: 'model', kind: 'model',
compressed: modelCompressed, compressed: false,
}) })
} else { } else {
const category = classifyAssetCategory(pf.filename) const category = classifyAssetCategory(pf.filename)
+3 -3
View File
@@ -113,7 +113,7 @@ export async function stageUpload(
// Upload original files to Nextcloud Drive // Upload original files to Nextcloud Drive
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Upload original files to Nextcloud Drive (no Blender compression). */ /** Upload original files to Nextcloud Drive. */
export async function uploadDrive( export async function uploadDrive(
stagingId: string, stagingId: string,
secret: string, secret: string,
@@ -142,10 +142,10 @@ export async function uploadDrive(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Upload files to GitHub (with Blender compression) // Upload files to GitHub
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Upload files to GitHub (with Blender compression). */ /** Upload files to GitHub. */
export async function uploadGit( export async function uploadGit(
stagingId: string, stagingId: string,
secret: string, secret: string,
+6 -6
View File
@@ -2,10 +2,10 @@
// Client-side folder validation // Client-side folder validation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import { TEXTURE_EXTENSIONS } from '@/lib/constants' import { ASSET_EXTENSIONS, TEXTURE_EXTENSIONS } from '@/lib/constants'
import type { TextureFile } from '@/lib/client-types' import type { TextureFile } from '@/lib/client-types'
const TEXTURE_EXT_ARRAY = [...TEXTURE_EXTENSIONS] const SUPPORT_FILE_EXT_ARRAY = [...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS]
/** Discriminated union: either valid (with model) or invalid (with errors). */ /** Discriminated union: either valid (with model) or invalid (with errors). */
export type ValidationResult = export type ValidationResult =
@@ -18,20 +18,20 @@ export function validateFolder(files: File[]): ValidationResult {
const modelFiles = files.filter((f) => { const modelFiles = files.filter((f) => {
const name = f.name.toLowerCase() const name = f.name.toLowerCase()
return name === 'model.glb' return name === 'model.gltf'
}) })
if (modelFiles.length === 0) { if (modelFiles.length === 0) {
return { ok: false, errors: ['model.glb manquant (obligatoire)'] } return { ok: false, errors: ['model.gltf manquant (obligatoire)'] }
} }
if (modelFiles.length > 1) { if (modelFiles.length > 1) {
return { ok: false, errors: ['Un seul fichier model.glb est autorise'] } return { ok: false, errors: ['Un seul fichier model.gltf est autorise'] }
} }
const textureFiles = files.filter((f) => { const textureFiles = files.filter((f) => {
const ext = f.name.slice(f.name.lastIndexOf('.')).toLowerCase() const ext = f.name.slice(f.name.lastIndexOf('.')).toLowerCase()
return TEXTURE_EXT_ARRAY.includes(ext) return SUPPORT_FILE_EXT_ARRAY.includes(ext)
}) })
for (const tf of textureFiles) { for (const tf of textureFiles) {