diff --git a/app/api/upload/check/route.ts b/app/api/upload/check/route.ts
index 85140de..868b282 100644
--- a/app/api/upload/check/route.ts
+++ b/app/api/upload/check/route.ts
@@ -4,8 +4,7 @@ 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 { getErrorMessage } from '@/lib/guards'
+import { readStagingRequestBody, uploadErrorResponse } from '@/lib/upload-request'
import type { FileDiff } from '@/lib/types'
export const runtime = 'nodejs'
@@ -22,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 = getErrorMessage(err)
- 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)
@@ -53,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 = getErrorMessage(err)
- return NextResponse.json({ success: false, error: message }, { status: 500 })
+ return uploadErrorResponse(err, 500)
}
}
diff --git a/app/api/upload/drive/route.ts b/app/api/upload/drive/route.ts
index 61c232d..4da08d5 100644
--- a/app/api/upload/drive/route.ts
+++ b/app/api/upload/drive/route.ts
@@ -8,7 +8,12 @@ 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'
@@ -20,9 +25,9 @@ export async function POST(req: NextRequest) {
if (authError) return authError
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,
)
}
@@ -31,23 +36,18 @@ export async function POST(req: NextRequest) {
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 = getErrorMessage(err)
- 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'
@@ -79,10 +79,7 @@ export async function POST(req: NextRequest) {
})
} catch (err) {
const message = getErrorMessage(err, 'Erreur Nextcloud inconnue')
- return NextResponse.json(
- { success: false, error: `Drive echoue: ${message}` },
- { status: 500 },
- )
+ return uploadErrorMessageResponse(`Drive echoue: ${message}`, 500)
} finally {
releaseUploadLock(folderName)
}
diff --git a/app/api/upload/git/route.ts b/app/api/upload/git/route.ts
index f663f36..bd0583a 100644
--- a/app/api/upload/git/route.ts
+++ b/app/api/upload/git/route.ts
@@ -6,12 +6,26 @@ 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.
@@ -24,20 +38,15 @@ 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 = getErrorMessage(err)
- 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 {
@@ -45,6 +54,7 @@ export async function POST(req: NextRequest) {
filesToPush,
modelFilename,
compressed,
+ deliveryMode,
compressionError,
assetSummaries,
} = await ensurePreparedStagingAssets(stagingId)
@@ -59,12 +69,13 @@ export async function POST(req: NextRequest) {
classifyFileChanges(filesToPush, remoteFileMap, folderPath)
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.',
})
@@ -79,26 +90,22 @@ export async function POST(req: NextRequest) {
deletedFileNames,
)
- 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 = getErrorMessage(err, '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)
}
diff --git a/app/api/upload/stage/route.ts b/app/api/upload/stage/route.ts
index 9e12b9e..68d8179 100644
--- a/app/api/upload/stage/route.ts
+++ b/app/api/upload/stage/route.ts
@@ -2,7 +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 { getErrorMessage } from '@/lib/guards'
+import { uploadErrorResponse } from '@/lib/upload-request'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
@@ -16,7 +16,6 @@ export async function POST(req: NextRequest) {
const staged = await createStagingUpload(parsed.folderName, parsed.files, parsed.gitModelMode)
return NextResponse.json({ success: true, ...staged })
} catch (err) {
- const message = getErrorMessage(err)
- return NextResponse.json({ success: false, error: message }, { status: 400 })
+ return uploadErrorResponse(err, 400)
}
}
diff --git a/components/ModelViewer.tsx b/components/ModelViewer.tsx
index 50f599c..18d33dc 100644
--- a/components/ModelViewer.tsx
+++ b/components/ModelViewer.tsx
@@ -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 (
+
+
+
Preview 3D indisponible pour ce modele.
+
+ L'upload reste possible. {message ? `Detail technique : ${message}` : ''}
+
+
+
+ )
+}
+
+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
+ }
+
+ return this.props.children
+ }
+}
+
export default function ModelViewer({ url, assetUrls, filename, size }: ModelViewerProps) {
const canPreview = filename.toLowerCase().endsWith('.gltf')
const [stats, setStats] = useState(null)
- const [Scene, setScene] = useState(null)
+ const [Scene, setScene] = useState
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 (
@@ -87,7 +135,13 @@ export default function ModelViewer({ url, assetUrls, filename, size }: ModelVie
)}
-
+ {sceneError ? (
+
+ ) : (
+
+
+
+ )}
)
}
diff --git a/components/SceneViewer.tsx b/components/SceneViewer.tsx
index 5b99e20..158a358 100644
--- a/components/SceneViewer.tsx
+++ b/components/SceneViewer.tsx
@@ -93,7 +93,7 @@ function supportsAlphaMap(material: Material): material is AlphaMapMaterial {
return 'alphaMap' in material
}
-function isAlphaImageSource(image: object | null | undefined): image is AlphaImageSource {
+function isAlphaImageSource(image: unknown): image is AlphaImageSource {
return image instanceof HTMLImageElement
|| image instanceof HTMLCanvasElement
|| (typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap)
@@ -103,7 +103,7 @@ function createAlphaMapTexture(texture: Texture) {
const cachedTexture = alphaMapTextureCache.get(texture)
if (cachedTexture) return cachedTexture
- const image = texture.image as object | null | undefined
+ const image = texture.image
if (!isAlphaImageSource(image)) {
texture.flipY = false
@@ -243,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))
diff --git a/components/upload/DriveStatusLine.tsx b/components/upload/DriveStatusLine.tsx
index 544b25b..908a84e 100644
--- a/components/upload/DriveStatusLine.tsx
+++ b/components/upload/DriveStatusLine.tsx
@@ -1,8 +1,8 @@
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
+ driveStatus: DriveStatus
driveError?: string
}
diff --git a/components/upload/FolderCard.tsx b/components/upload/FolderCard.tsx
index e63ab0e..1452f4c 100644
--- a/components/upload/FolderCard.tsx
+++ b/components/upload/FolderCard.tsx
@@ -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'
@@ -63,6 +63,13 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F
)}
+ {entry.uploadWarning && (
+
+
+ {entry.uploadWarning}
+
+ )}
+
{entry.status === 'uploading' && (
@@ -62,7 +66,7 @@ export function useUploadOrchestrator({
} | null>(null)
const abortRef = useRef
(null)
- const checkResultRef = useRef({ exists: false, diffs: [] })
+ const checkResultRef = useRef({ exists: false, diffs: [] })
const uploadActionRef = useRef(false)
const stagingIdRef = useRef(null)
@@ -101,6 +105,7 @@ 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])
@@ -135,6 +140,7 @@ export function useUploadOrchestrator({
status: 'uploading',
progress: 1,
error: undefined,
+ uploadWarning: undefined,
driveStatus: 'uploading',
driveError: undefined,
})
@@ -221,7 +227,7 @@ export function useUploadOrchestrator({
folderName: folder.folderName,
stagingId: staged.stagingId,
})
- let check: CheckResult
+ let check: CheckUploadResult
try {
check = await checkFolderDiffs(
@@ -238,6 +244,7 @@ export function useUploadOrchestrator({
}
checkResultRef.current = check
+ updateEntry(0, { uploadWarning: check.warning })
if (check.exists) {
if (check.diffs.length === 0) {
@@ -289,6 +296,7 @@ export function useUploadOrchestrator({
status: 'uploading',
progress: 0,
error: undefined,
+ uploadWarning: undefined,
driveStatus: 'skipped',
})
diff --git a/lib/client-types.ts b/lib/client-types.ts
index 73be2d6..eda1f5f 100644
--- a/lib/client-types.ts
+++ b/lib/client-types.ts
@@ -1,11 +1,11 @@
-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
@@ -14,6 +14,7 @@ export interface FolderEntry {
status: FileStatus
progress: number
error?: string
+ uploadWarning?: string
filename?: string
modelUrl?: string
assetUrls: Record
diff --git a/lib/github.ts b/lib/github.ts
index dd4ab1f..cdda391 100644
--- a/lib/github.ts
+++ b/lib/github.ts
@@ -19,17 +19,33 @@ 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}"`)
}
function isLfsFile(filePath: string): boolean {
@@ -73,12 +89,17 @@ interface LfsObject {
contentBase64: string
}
+interface LfsBatchAction {
+ href: string
+ header?: Record
+}
+
interface LfsBatchObject {
oid: string
size: number
actions?: {
- upload?: { href: string; header?: Record }
- verify?: { href: string; header?: Record }
+ upload?: LfsBatchAction
+ verify?: LfsBatchAction
}
error?: { code: number; message: string }
}
@@ -87,7 +108,7 @@ function isStringRecord(value: unknown): value is Record {
return isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string')
}
-function parseLfsAction(value: unknown) {
+function parseLfsAction(value: unknown): LfsBatchAction | undefined {
if (!isRecord(value) || typeof value.href !== 'string') return undefined
return {
href: value.href,
diff --git a/lib/parse-upload.ts b/lib/parse-upload.ts
index b2e58fb..7addb26 100644
--- a/lib/parse-upload.ts
+++ b/lib/parse-upload.ts
@@ -19,8 +19,16 @@ function parseGitModelMode(value: FormDataEntryValue | null): GitModelMode {
export async function parseMultiUpload(req: NextRequest): Promise {
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')
diff --git a/lib/prepare-git-assets.ts b/lib/prepare-git-assets.ts
index 6a96a1e..bb6c1e1 100644
--- a/lib/prepare-git-assets.ts
+++ b/lib/prepare-git-assets.ts
@@ -7,6 +7,7 @@ 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 { GitModelMode, ParsedFile, PreparedAssetSummary, PreparedGitAssetsResult, PushFile } from '@/lib/types'
@@ -18,6 +19,30 @@ interface PrepareGitAssetsParams {
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)
+}
+
+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()
const normalizedGroups = new Map>()
@@ -75,7 +100,7 @@ function rewriteGltfUris(value: JsonValue, filenameMap: Map): Js
function prepareModelBuffer(buffer: Buffer, filenameMap: Map) {
if (filenameMap.size === 0) return buffer
- const parsed = JSON.parse(buffer.toString('utf-8')) as JsonValue
+ const parsed = parseJsonValue(buffer.toString('utf-8'))
return Buffer.from(JSON.stringify(rewriteGltfUris(parsed, filenameMap), null, 2), 'utf-8')
}
@@ -130,7 +155,14 @@ async function prepareSeparateFiles(
})
}
- return { filesToPush, modelFilename, assetSummaries, compressed, compressionError }
+ return {
+ filesToPush,
+ modelFilename,
+ assetSummaries,
+ compressed,
+ compressionError,
+ deliveryMode: 'keep-gltf' as const,
+ }
}
async function prepareDracoGlb(
@@ -160,22 +192,35 @@ async function prepareDracoGlb(
await writeFile(join(tmpFolder, filename), content)
}
- const result = await compressWithBlender(inputModelPath, outputModelPath)
- if (!result.success || !existsSync(outputModelPath)) {
- throw new Error(result.error || 'Compression Blender echouee')
- }
+ 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'
+ 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,
+ 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(() => {})
diff --git a/lib/types.ts b/lib/types.ts
index 53fee88..7e4e8fa 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -15,11 +15,13 @@ 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 {
@@ -40,11 +42,30 @@ export interface StagingUploadResult {
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
}
diff --git a/lib/upload-api.ts b/lib/upload-api.ts
index 1a5fd4e..e8c7260 100644
--- a/lib/upload-api.ts
+++ b/lib/upload-api.ts
@@ -1,16 +1,46 @@
-import { isRecord } from './guards'
+import { getErrorMessage, isRecord } from './guards'
import type { FolderEntry } from './client-types'
-import type { DriveAction, FileDiff, GitModelMode, StagingUploadResult } from './types'
+import type {
+ CheckUploadResult,
+ DriveAction,
+ DriveUploadResult,
+ FileDiff,
+ GitModelMode,
+ GitUploadResult,
+ StagingUploadResult,
+} from './types'
-export interface CheckResult {
- exists: boolean
- diffs: FileDiff[]
+interface CompressionWarningPayload {
+ compressionError?: unknown
}
+interface SuccessfulUploadData extends CompressionWarningPayload {
+ success: true
+ exists?: unknown
+ diffs?: unknown
+ stagingId?: unknown
+ folderName?: unknown
+ filesCount?: unknown
+}
+
+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',
@@ -18,10 +48,35 @@ function getUploadJsonHeaders(secret: string) {
}
}
+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'
@@ -50,27 +105,34 @@ export async function checkFolderDiffs(
stagingId: string,
secret: string,
signal?: AbortSignal,
-): Promise {
- const res = await fetch('/api/upload/check', {
- method: 'POST',
- headers: getUploadJsonHeaders(secret),
- body: JSON.stringify({ stagingId }),
- signal,
- })
-
- const data: unknown = await res.json()
+): Promise {
+ const { res, data } = await postUploadJson('/api/upload/check', secret, { stagingId }, signal)
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(
@@ -89,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})`))
}
@@ -109,24 +171,15 @@ export async function uploadDrive(
secret: string,
action: DriveAction,
signal?: AbortSignal,
-): Promise<{ success: boolean; error?: string }> {
+): Promise {
try {
- const res = await fetch('/api/upload/drive', {
- method: 'POST',
- headers: getUploadJsonHeaders(secret),
- 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 (isAbortError(err)) {
- return { success: false, error: 'Upload annule' }
- }
- return { success: false, error: 'Erreur reseau (Drive)' }
+ return { success: false, error: getNetworkUploadError(err, 'Erreur Drive') }
}
}
@@ -135,30 +188,25 @@ export async function uploadGit(
secret: string,
onProgress: (pct: number) => void,
signal?: AbortSignal,
-): Promise<{ success: boolean; filename?: string; error?: string }> {
+): Promise {
onProgress(10)
try {
- const res = await fetch('/api/upload/git', {
- method: 'POST',
- headers: getUploadJsonHeaders(secret),
- 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 (isAbortError(err)) {
- 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') }
}
}
diff --git a/lib/upload-request.ts b/lib/upload-request.ts
index 34f0846..0093f8c 100644
--- a/lib/upload-request.ts
+++ b/lib/upload-request.ts
@@ -1,4 +1,5 @@
-import { isRecord } from './guards'
+import { NextResponse } from 'next/server'
+import { getErrorMessage, isRecord } from './guards'
import type { DriveAction } from './types'
interface StagingRequestBody {
@@ -9,6 +10,21 @@ interface DriveRequestBody extends StagingRequestBody {
action: DriveAction
}
+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 {
if (!isRecord(value) || typeof value.stagingId !== 'string' || value.stagingId.trim() === '') {
throw new Error('stagingId manquant')
@@ -17,9 +33,22 @@ export function parseStagingRequestBody(value: unknown): StagingRequestBody {
return { stagingId: value.stagingId }
}
+export async function readStagingRequestBody(req: Request): Promise {
+ 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 {
+ const body: unknown = await req.json()
+ return parseDriveRequestBody(body)
}
diff --git a/lib/upload-staging.ts b/lib/upload-staging.ts
index 6b76bc6..4627a4e 100644
--- a/lib/upload-staging.ts
+++ b/lib/upload-staging.ts
@@ -3,9 +3,11 @@ 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 {
+ AssetCategory,
GitModelMode,
ParsedFile,
PreparedAssetSummary,
@@ -26,6 +28,7 @@ interface StagedOriginalFile {
interface StagedPreparedData {
modelFilename: string
compressed: boolean
+ deliveryMode?: GitModelMode
compressionError?: string
assetSummaries: PreparedAssetSummary[]
}
@@ -55,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 })
}
@@ -126,7 +192,7 @@ export async function createStagingUpload(
export async function readStagedManifest(stagingId: string): Promise {
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 {
@@ -179,6 +245,7 @@ export async function ensurePreparedStagingAssets(stagingId: string): Promise