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
View File
@@ -20,6 +20,7 @@ export interface FolderEntry {
error?: string
filename?: string
modelUrl?: string
assetUrls?: Record<string, string>
viewerOpen?: boolean
warnings: string[]
driveStatus?: DriveStatus
+4 -3
View File
@@ -2,12 +2,13 @@
// 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 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) */
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'
+2 -2
View File
@@ -26,9 +26,9 @@ export interface DiffResult {
* the remote file map.
*
* 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
* 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).
* - Orphan remote files: classified as deletions.
*/
+15
View File
@@ -49,6 +49,7 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
}
const parsed: ParsedFile[] = []
let modelCount = 0
for (let i = 0; i < fileEntries.length; i++) {
const file = fileEntries[i]
@@ -79,10 +80,24 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
}
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())
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 }
}
+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 { classifyAssetCategory } from '@/lib/asset-classification'
import type { ParsedFile, PreparedAssetSummary } from '@/lib/types'
@@ -40,36 +35,11 @@ export async function prepareGitAssets({
if (pf.isModel) {
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({
filename: pf.filename,
kind: 'model',
compressed: modelCompressed,
compressed: false,
})
} else {
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 (no Blender compression). */
/** Upload original files to Nextcloud Drive. */
export async function uploadDrive(
stagingId: 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(
stagingId: string,
secret: string,
+6 -6
View File
@@ -2,10 +2,10 @@
// 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'
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). */
export type ValidationResult =
@@ -18,20 +18,20 @@ export function validateFolder(files: File[]): ValidationResult {
const modelFiles = files.filter((f) => {
const name = f.name.toLowerCase()
return name === 'model.glb'
return name === 'model.gltf'
})
if (modelFiles.length === 0) {
return { ok: false, errors: ['model.glb manquant (obligatoire)'] }
return { ok: false, errors: ['model.gltf manquant (obligatoire)'] }
}
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 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) {