fix: harden upload resilience and contracts
This commit is contained in:
@@ -4,8 +4,7 @@ import { getRemoteFolder } from '@/lib/github'
|
|||||||
import { classifyFileChanges } from '@/lib/diff-files'
|
import { classifyFileChanges } from '@/lib/diff-files'
|
||||||
import { getModelFolderPath } from '@/lib/model-paths'
|
import { getModelFolderPath } from '@/lib/model-paths'
|
||||||
import { ensurePreparedStagingAssets } from '@/lib/upload-staging'
|
import { ensurePreparedStagingAssets } from '@/lib/upload-staging'
|
||||||
import { parseStagingRequestBody } from '@/lib/upload-request'
|
import { readStagingRequestBody, uploadErrorResponse } from '@/lib/upload-request'
|
||||||
import { getErrorMessage } from '@/lib/guards'
|
|
||||||
import type { FileDiff } from '@/lib/types'
|
import type { FileDiff } from '@/lib/types'
|
||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
@@ -22,15 +21,13 @@ export async function POST(req: NextRequest) {
|
|||||||
let stagingId: string
|
let stagingId: string
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body: unknown = await req.json()
|
stagingId = (await readStagingRequestBody(req)).stagingId
|
||||||
stagingId = parseStagingRequestBody(body).stagingId
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = getErrorMessage(err)
|
return uploadErrorResponse(err, 400)
|
||||||
return NextResponse.json({ success: false, error: message }, { status: 400 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { folderName, filesToPush } = await ensurePreparedStagingAssets(stagingId)
|
const { folderName, filesToPush, deliveryMode, compressionError } = await ensurePreparedStagingAssets(stagingId)
|
||||||
const folderPath = getModelFolderPath(folderName)
|
const folderPath = getModelFolderPath(folderName)
|
||||||
const { exists, files } = await getRemoteFolder(folderPath)
|
const { exists, files } = await getRemoteFolder(folderPath)
|
||||||
|
|
||||||
@@ -53,12 +50,18 @@ export async function POST(req: NextRequest) {
|
|||||||
exists: true,
|
exists: true,
|
||||||
path: folderPath,
|
path: folderPath,
|
||||||
diffs,
|
diffs,
|
||||||
|
deliveryMode,
|
||||||
|
compressionError,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true, exists: false })
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
exists: false,
|
||||||
|
deliveryMode,
|
||||||
|
compressionError,
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = getErrorMessage(err)
|
return uploadErrorResponse(err, 500)
|
||||||
return NextResponse.json({ success: false, error: message }, { status: 500 })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ import {
|
|||||||
findNextVersion,
|
findNextVersion,
|
||||||
} from '@/lib/nextcloud'
|
} from '@/lib/nextcloud'
|
||||||
import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock'
|
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 { getErrorMessage } from '@/lib/guards'
|
||||||
import type { DriveAction } from '@/lib/types'
|
import type { DriveAction } from '@/lib/types'
|
||||||
|
|
||||||
@@ -20,9 +25,9 @@ export async function POST(req: NextRequest) {
|
|||||||
if (authError) return authError
|
if (authError) return authError
|
||||||
|
|
||||||
if (!process.env.NEXTCLOUD_URL || !process.env.NEXTCLOUD_SHARE_TOKEN) {
|
if (!process.env.NEXTCLOUD_URL || !process.env.NEXTCLOUD_SHARE_TOKEN) {
|
||||||
return NextResponse.json(
|
return uploadErrorMessageResponse(
|
||||||
{ success: false, error: 'Nextcloud non configure sur le serveur (NEXTCLOUD_URL, NEXTCLOUD_SHARE_TOKEN)' },
|
'Nextcloud non configure sur le serveur (NEXTCLOUD_URL, NEXTCLOUD_SHARE_TOKEN)',
|
||||||
{ status: 500 },
|
500,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,23 +36,18 @@ export async function POST(req: NextRequest) {
|
|||||||
let action: DriveAction
|
let action: DriveAction
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body: unknown = await req.json()
|
const parsedBody = await readDriveRequestBody(req)
|
||||||
const parsedBody = parseDriveRequestBody(body)
|
|
||||||
action = parsedBody.action
|
action = parsedBody.action
|
||||||
const stagingId = parsedBody.stagingId
|
const stagingId = parsedBody.stagingId
|
||||||
const staged = await readStagedOriginalFiles(stagingId)
|
const staged = await readStagedOriginalFiles(stagingId)
|
||||||
folderName = staged.folderName
|
folderName = staged.folderName
|
||||||
parsedFiles = staged.files
|
parsedFiles = staged.files
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = getErrorMessage(err)
|
return uploadErrorResponse(err, 400)
|
||||||
return NextResponse.json({ success: false, error: message }, { status: 400 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!acquireUploadLock(folderName)) {
|
if (!acquireUploadLock(folderName)) {
|
||||||
return NextResponse.json(
|
return uploadLockConflictResponse()
|
||||||
{ success: false, error: 'Un upload est deja en cours pour ce dossier. Patientez quelques secondes.' },
|
|
||||||
{ status: 409 },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const basePath = process.env.NEXTCLOUD_BASE_PATH || 'Models'
|
const basePath = process.env.NEXTCLOUD_BASE_PATH || 'Models'
|
||||||
@@ -79,10 +79,7 @@ export async function POST(req: NextRequest) {
|
|||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = getErrorMessage(err, 'Erreur Nextcloud inconnue')
|
const message = getErrorMessage(err, 'Erreur Nextcloud inconnue')
|
||||||
return NextResponse.json(
|
return uploadErrorMessageResponse(`Drive echoue: ${message}`, 500)
|
||||||
{ success: false, error: `Drive echoue: ${message}` },
|
|
||||||
{ status: 500 },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
releaseUploadLock(folderName)
|
releaseUploadLock(folderName)
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-29
@@ -6,12 +6,26 @@ import { classifyFileChanges } from '@/lib/diff-files'
|
|||||||
import { getModelFolderPath } from '@/lib/model-paths'
|
import { getModelFolderPath } from '@/lib/model-paths'
|
||||||
import { cleanupStagingUpload, ensurePreparedStagingAssets, readStagedManifest } from '@/lib/upload-staging'
|
import { cleanupStagingUpload, ensurePreparedStagingAssets, readStagedManifest } from '@/lib/upload-staging'
|
||||||
import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock'
|
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'
|
import { getErrorMessage } from '@/lib/guards'
|
||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
export const dynamic = 'force-dynamic'
|
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
|
* POST /api/upload/git
|
||||||
* Upload prepared files and push to GitHub via Octokit.
|
* Upload prepared files and push to GitHub via Octokit.
|
||||||
@@ -24,20 +38,15 @@ export async function POST(req: NextRequest) {
|
|||||||
let stagingId: string
|
let stagingId: string
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body: unknown = await req.json()
|
stagingId = (await readStagingRequestBody(req)).stagingId
|
||||||
stagingId = parseStagingRequestBody(body).stagingId
|
|
||||||
const manifest = await readStagedManifest(stagingId)
|
const manifest = await readStagedManifest(stagingId)
|
||||||
folderName = manifest.folderName
|
folderName = manifest.folderName
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = getErrorMessage(err)
|
return uploadErrorResponse(err, 400)
|
||||||
return NextResponse.json({ success: false, error: message }, { status: 400 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!acquireUploadLock(folderName)) {
|
if (!acquireUploadLock(folderName)) {
|
||||||
return NextResponse.json(
|
return uploadLockConflictResponse()
|
||||||
{ success: false, error: 'Un upload est deja en cours pour ce dossier. Patientez quelques secondes.' },
|
|
||||||
{ status: 409 },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -45,6 +54,7 @@ export async function POST(req: NextRequest) {
|
|||||||
filesToPush,
|
filesToPush,
|
||||||
modelFilename,
|
modelFilename,
|
||||||
compressed,
|
compressed,
|
||||||
|
deliveryMode,
|
||||||
compressionError,
|
compressionError,
|
||||||
assetSummaries,
|
assetSummaries,
|
||||||
} = await ensurePreparedStagingAssets(stagingId)
|
} = await ensurePreparedStagingAssets(stagingId)
|
||||||
@@ -59,12 +69,13 @@ export async function POST(req: NextRequest) {
|
|||||||
classifyFileChanges(filesToPush, remoteFileMap, folderPath)
|
classifyFileChanges(filesToPush, remoteFileMap, folderPath)
|
||||||
|
|
||||||
if (changedFilesToPush.length === 0 && deletePaths.length === 0) {
|
if (changedFilesToPush.length === 0 && deletePaths.length === 0) {
|
||||||
await cleanupStagingUpload(stagingId).catch(() => {})
|
await cleanupCompletedStagingUpload(stagingId)
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
folderName,
|
folderName,
|
||||||
filesCount: 0,
|
filesCount: 0,
|
||||||
compressed,
|
compressed,
|
||||||
|
deliveryMode,
|
||||||
compressionError: compressionError || undefined,
|
compressionError: compressionError || undefined,
|
||||||
message: 'Aucun fichier modifie — rien a envoyer.',
|
message: 'Aucun fichier modifie — rien a envoyer.',
|
||||||
})
|
})
|
||||||
@@ -79,26 +90,22 @@ export async function POST(req: NextRequest) {
|
|||||||
deletedFileNames,
|
deletedFileNames,
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
const { commitUrl } = await pushAllToGitHub(changedFilesToPush, deletePaths, commitMessage)
|
||||||
const { commitUrl } = await pushAllToGitHub(changedFilesToPush, deletePaths, commitMessage)
|
await cleanupCompletedStagingUpload(stagingId)
|
||||||
await cleanupStagingUpload(stagingId).catch(() => {})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
folderName,
|
folderName,
|
||||||
filesCount: changedFilesToPush.length,
|
filesCount: changedFilesToPush.length,
|
||||||
compressed,
|
compressed,
|
||||||
compressionError: compressionError || undefined,
|
deliveryMode,
|
||||||
message: `${changedFilesToPush.length} fichier(s) modifie(s) envoye(s) sur GitHub en un seul commit.`,
|
compressionError: compressionError || undefined,
|
||||||
commitUrl,
|
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')
|
} catch (err) {
|
||||||
return NextResponse.json(
|
const message = getErrorMessage(err, 'Erreur GitHub inconnue')
|
||||||
{ success: false, error: `Push GitHub echoue: ${message}` },
|
return uploadErrorMessageResponse(`Upload GitHub echoue: ${message}`, 500)
|
||||||
{ status: 500 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
releaseUploadLock(folderName)
|
releaseUploadLock(folderName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { validateUploadSecret } from '@/lib/auth'
|
import { validateUploadSecret } from '@/lib/auth'
|
||||||
import { parseMultiUpload } from '@/lib/parse-upload'
|
import { parseMultiUpload } from '@/lib/parse-upload'
|
||||||
import { createStagingUpload } from '@/lib/upload-staging'
|
import { createStagingUpload } from '@/lib/upload-staging'
|
||||||
import { getErrorMessage } from '@/lib/guards'
|
import { uploadErrorResponse } from '@/lib/upload-request'
|
||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
export const dynamic = 'force-dynamic'
|
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)
|
const staged = await createStagingUpload(parsed.folderName, parsed.files, parsed.gitModelMode)
|
||||||
return NextResponse.json({ success: true, ...staged })
|
return NextResponse.json({ success: true, ...staged })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = getErrorMessage(err)
|
return uploadErrorResponse(err, 400)
|
||||||
return NextResponse.json({ success: false, error: message }, { status: 400 })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { Component, useEffect, useState } from 'react'
|
||||||
|
import type { ComponentType, ReactNode } from 'react'
|
||||||
import type { ModelStats } from './SceneViewer'
|
import type { ModelStats } from './SceneViewer'
|
||||||
|
|
||||||
interface ModelViewerProps {
|
interface ModelViewerProps {
|
||||||
@@ -10,10 +11,51 @@ interface ModelViewerProps {
|
|||||||
size: string
|
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'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) {
|
export default function ModelViewer({ url, assetUrls, filename, size }: ModelViewerProps) {
|
||||||
const canPreview = filename.toLowerCase().endsWith('.gltf')
|
const canPreview = filename.toLowerCase().endsWith('.gltf')
|
||||||
const [stats, setStats] = useState<ModelStats | null>(null)
|
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
|
url: string
|
||||||
assetUrls: Record<string, string>
|
assetUrls: Record<string, string>
|
||||||
onStatsReady: (stats: ModelStats) => void
|
onStatsReady: (stats: ModelStats) => void
|
||||||
@@ -23,13 +65,19 @@ export default function ModelViewer({ url, assetUrls, filename, size }: ModelVie
|
|||||||
if (!canPreview) return
|
if (!canPreview) return
|
||||||
|
|
||||||
let cancel = false
|
let cancel = false
|
||||||
|
setSceneError(null)
|
||||||
|
setStats(null)
|
||||||
|
|
||||||
import('./SceneViewer').then((mod) => {
|
import('./SceneViewer')
|
||||||
if (!cancel) setScene(() => mod.default)
|
.then((mod) => {
|
||||||
})
|
if (!cancel) setScene(() => mod.default)
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (!cancel) setSceneError(getPreviewErrorMessage(error))
|
||||||
|
})
|
||||||
|
|
||||||
return () => { cancel = true }
|
return () => { cancel = true }
|
||||||
}, [canPreview])
|
}, [canPreview, url])
|
||||||
|
|
||||||
if (!canPreview) {
|
if (!canPreview) {
|
||||||
return (
|
return (
|
||||||
@@ -87,7 +135,13 @@ export default function ModelViewer({ url, assetUrls, filename, size }: ModelVie
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Scene url={url} assetUrls={assetUrls} onStatsReady={setStats} />
|
{sceneError ? (
|
||||||
|
<PreviewFallback message={sceneError} />
|
||||||
|
) : (
|
||||||
|
<PreviewErrorBoundary key={url}>
|
||||||
|
<Scene url={url} assetUrls={assetUrls} onStatsReady={setStats} />
|
||||||
|
</PreviewErrorBoundary>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ function supportsAlphaMap(material: Material): material is AlphaMapMaterial {
|
|||||||
return 'alphaMap' in material
|
return 'alphaMap' in material
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAlphaImageSource(image: object | null | undefined): image is AlphaImageSource {
|
function isAlphaImageSource(image: unknown): image is AlphaImageSource {
|
||||||
return image instanceof HTMLImageElement
|
return image instanceof HTMLImageElement
|
||||||
|| image instanceof HTMLCanvasElement
|
|| image instanceof HTMLCanvasElement
|
||||||
|| (typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap)
|
|| (typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap)
|
||||||
@@ -103,7 +103,7 @@ function createAlphaMapTexture(texture: Texture) {
|
|||||||
const cachedTexture = alphaMapTextureCache.get(texture)
|
const cachedTexture = alphaMapTextureCache.get(texture)
|
||||||
if (cachedTexture) return cachedTexture
|
if (cachedTexture) return cachedTexture
|
||||||
|
|
||||||
const image = texture.image as object | null | undefined
|
const image = texture.image
|
||||||
|
|
||||||
if (!isAlphaImageSource(image)) {
|
if (!isAlphaImageSource(image)) {
|
||||||
texture.flipY = false
|
texture.flipY = false
|
||||||
@@ -243,7 +243,7 @@ function Model({
|
|||||||
loader.manager.setURLModifier((requestedUrl) => resolveAssetUrl(requestedUrl, assetUrls))
|
loader.manager.setURLModifier((requestedUrl) => resolveAssetUrl(requestedUrl, assetUrls))
|
||||||
})
|
})
|
||||||
const opacityMapEntries = getOpacityMapEntries(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(() => {
|
useEffect(() => {
|
||||||
onStatsReady(getModelStats(scene, assetUrls))
|
onStatsReady(getModelStats(scene, assetUrls))
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { SpinnerIcon, XIcon, WarningIcon } from '@/components/ui/icons'
|
import { SpinnerIcon, XIcon, WarningIcon } from '@/components/ui/icons'
|
||||||
import type { FolderEntry } from '@/lib/client-types'
|
import type { DriveStatus } from '@/lib/client-types'
|
||||||
|
|
||||||
interface DriveStatusLineProps {
|
interface DriveStatusLineProps {
|
||||||
driveStatus: NonNullable<FolderEntry['driveStatus']>
|
driveStatus: DriveStatus
|
||||||
driveError?: string
|
driveError?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import type { FolderEntry } from '@/lib/client-types'
|
import type { FolderEntry } from '@/lib/client-types'
|
||||||
import { formatBytes } from '@/lib/format-bytes'
|
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 DriveStatusLine from './DriveStatusLine'
|
||||||
import WarningBanner from './WarningBanner'
|
import WarningBanner from './WarningBanner'
|
||||||
|
|
||||||
@@ -63,6 +63,13 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F
|
|||||||
<DriveStatusLine driveStatus={entry.driveStatus} driveError={entry.driveError} />
|
<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' && (
|
{entry.status === 'uploading' && (
|
||||||
<div className="mt-1.5 w-full h-1 bg-black-700 rounded-full overflow-hidden">
|
<div className="mt-1.5 w-full h-1 bg-black-700 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import type { FolderEntry } from '@/lib/client-types'
|
import type { FolderEntry } from '@/lib/client-types'
|
||||||
import { validateFolder } from '@/lib/validate-folder'
|
import { validateFolder } from '@/lib/validate-folder'
|
||||||
|
import { getErrorMessage } from '@/lib/guards'
|
||||||
import { FolderIcon } from '@/components/ui/icons'
|
import { FolderIcon } from '@/components/ui/icons'
|
||||||
|
|
||||||
function buildAssetUrls(model: File, supportFiles: File[]) {
|
function buildAssetUrls(model: File, supportFiles: File[]) {
|
||||||
@@ -156,8 +157,8 @@ export default function FolderDropzone({
|
|||||||
if (droppedFiles.length === 0) return
|
if (droppedFiles.length === 0) return
|
||||||
|
|
||||||
await processFiles(droppedFiles)
|
await processFiles(droppedFiles)
|
||||||
} catch {
|
} catch (err) {
|
||||||
onError('Impossible de lire le dossier depose')
|
onError(`Impossible de lire le dossier depose: ${getErrorMessage(err)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
import { useState, useRef, useCallback } from 'react'
|
import { useState, useRef, useCallback } from 'react'
|
||||||
import { getErrorMessage } from '@/lib/guards'
|
import { getErrorMessage } from '@/lib/guards'
|
||||||
import type { FolderEntry } from '@/lib/client-types'
|
import type { FolderEntry } from '@/lib/client-types'
|
||||||
import type { DriveAction, FileDiff, GitModelMode } from '@/lib/types'
|
import type {
|
||||||
|
CheckUploadResult,
|
||||||
|
DriveAction,
|
||||||
|
FileDiff,
|
||||||
|
GitModelMode,
|
||||||
|
} from '@/lib/types'
|
||||||
import { checkFolderDiffs, stageUpload, uploadDrive, uploadGit } from '@/lib/upload-api'
|
import { checkFolderDiffs, stageUpload, uploadDrive, uploadGit } from '@/lib/upload-api'
|
||||||
import type { CheckResult } from '@/lib/upload-api'
|
|
||||||
|
|
||||||
type UploadLogDetails = Record<string, string | number | boolean | undefined>
|
type UploadLogDetails = Record<string, string | number | boolean | undefined>
|
||||||
|
|
||||||
@@ -62,7 +66,7 @@ export function useUploadOrchestrator({
|
|||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
const abortRef = useRef<AbortController | 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 uploadActionRef = useRef(false)
|
||||||
const stagingIdRef = useRef<string | null>(null)
|
const stagingIdRef = useRef<string | null>(null)
|
||||||
|
|
||||||
@@ -101,6 +105,7 @@ export function useUploadOrchestrator({
|
|||||||
status: gitResult.success ? 'success' : 'error',
|
status: gitResult.success ? 'success' : 'error',
|
||||||
progress: gitResult.success ? 100 : 0,
|
progress: gitResult.success ? 100 : 0,
|
||||||
error: gitResult.success ? undefined : gitResult.error,
|
error: gitResult.success ? undefined : gitResult.error,
|
||||||
|
uploadWarning: gitResult.success ? gitResult.warning : undefined,
|
||||||
filename: gitResult.filename,
|
filename: gitResult.filename,
|
||||||
})
|
})
|
||||||
}, [updateEntry])
|
}, [updateEntry])
|
||||||
@@ -135,6 +140,7 @@ export function useUploadOrchestrator({
|
|||||||
status: 'uploading',
|
status: 'uploading',
|
||||||
progress: 1,
|
progress: 1,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
|
uploadWarning: undefined,
|
||||||
driveStatus: 'uploading',
|
driveStatus: 'uploading',
|
||||||
driveError: undefined,
|
driveError: undefined,
|
||||||
})
|
})
|
||||||
@@ -221,7 +227,7 @@ export function useUploadOrchestrator({
|
|||||||
folderName: folder.folderName,
|
folderName: folder.folderName,
|
||||||
stagingId: staged.stagingId,
|
stagingId: staged.stagingId,
|
||||||
})
|
})
|
||||||
let check: CheckResult
|
let check: CheckUploadResult
|
||||||
|
|
||||||
try {
|
try {
|
||||||
check = await checkFolderDiffs(
|
check = await checkFolderDiffs(
|
||||||
@@ -238,6 +244,7 @@ export function useUploadOrchestrator({
|
|||||||
}
|
}
|
||||||
|
|
||||||
checkResultRef.current = check
|
checkResultRef.current = check
|
||||||
|
updateEntry(0, { uploadWarning: check.warning })
|
||||||
|
|
||||||
if (check.exists) {
|
if (check.exists) {
|
||||||
if (check.diffs.length === 0) {
|
if (check.diffs.length === 0) {
|
||||||
@@ -289,6 +296,7 @@ export function useUploadOrchestrator({
|
|||||||
status: 'uploading',
|
status: 'uploading',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
|
uploadWarning: undefined,
|
||||||
driveStatus: 'skipped',
|
driveStatus: 'skipped',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -1,11 +1,11 @@
|
|||||||
type FileStatus = 'pending' | 'uploading' | 'success' | 'error'
|
export type FileStatus = 'pending' | 'uploading' | 'success' | 'error'
|
||||||
|
|
||||||
export interface TextureFile {
|
export interface TextureFile {
|
||||||
name: string
|
name: string
|
||||||
file: File
|
file: File
|
||||||
}
|
}
|
||||||
|
|
||||||
type DriveStatus = 'pending' | 'uploading' | 'success' | 'error' | 'skipped'
|
export type DriveStatus = 'pending' | 'uploading' | 'success' | 'error' | 'skipped'
|
||||||
|
|
||||||
export interface FolderEntry {
|
export interface FolderEntry {
|
||||||
folderName: string
|
folderName: string
|
||||||
@@ -14,6 +14,7 @@ export interface FolderEntry {
|
|||||||
status: FileStatus
|
status: FileStatus
|
||||||
progress: number
|
progress: number
|
||||||
error?: string
|
error?: string
|
||||||
|
uploadWarning?: string
|
||||||
filename?: string
|
filename?: string
|
||||||
modelUrl?: string
|
modelUrl?: string
|
||||||
assetUrls: Record<string, string>
|
assetUrls: Record<string, string>
|
||||||
|
|||||||
+31
-10
@@ -19,17 +19,33 @@ function getOctokit(): Octokit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseRepoUrl(): { owner: string; repo: string } {
|
function parseRepoUrl(): { owner: string; repo: string } {
|
||||||
const url = process.env.GIT_REPO_URL
|
const url = process.env.GIT_REPO_URL?.trim()
|
||||||
if (!url) throw new Error('GIT_REPO_URL non configure')
|
if (!url) throw new Error('GIT_REPO_URL non configure')
|
||||||
|
|
||||||
const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/.]+)/)
|
const cleanRepoName = (repo: string) => repo.replace(/\/+$/, '').replace(/\.git$/, '')
|
||||||
const sshMatch = url.match(/github\.com:([^/]+)\/([^/.]+)/)
|
const shortMatch = url.match(/^([^/\s:]+)\/([^/\s]+)$/)
|
||||||
const shortMatch = url.match(/^([^/]+)\/([^/]+)$/)
|
if (shortMatch) {
|
||||||
|
return { owner: shortMatch[1], repo: cleanRepoName(shortMatch[2]) }
|
||||||
|
}
|
||||||
|
|
||||||
const match = httpsMatch || sshMatch || shortMatch
|
const sshMatch = url.match(/github\.com:([^/\s]+)\/(.+)$/)
|
||||||
if (!match) throw new Error(`Format GIT_REPO_URL invalide: "${url}"`)
|
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 {
|
function isLfsFile(filePath: string): boolean {
|
||||||
@@ -73,12 +89,17 @@ interface LfsObject {
|
|||||||
contentBase64: string
|
contentBase64: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LfsBatchAction {
|
||||||
|
href: string
|
||||||
|
header?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
interface LfsBatchObject {
|
interface LfsBatchObject {
|
||||||
oid: string
|
oid: string
|
||||||
size: number
|
size: number
|
||||||
actions?: {
|
actions?: {
|
||||||
upload?: { href: string; header?: Record<string, string> }
|
upload?: LfsBatchAction
|
||||||
verify?: { href: string; header?: Record<string, string> }
|
verify?: LfsBatchAction
|
||||||
}
|
}
|
||||||
error?: { code: number; message: string }
|
error?: { code: number; message: string }
|
||||||
}
|
}
|
||||||
@@ -87,7 +108,7 @@ function isStringRecord(value: unknown): value is Record<string, string> {
|
|||||||
return isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string')
|
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
|
if (!isRecord(value) || typeof value.href !== 'string') return undefined
|
||||||
return {
|
return {
|
||||||
href: value.href,
|
href: value.href,
|
||||||
|
|||||||
+9
-1
@@ -19,8 +19,16 @@ function parseGitModelMode(value: FormDataEntryValue | null): GitModelMode {
|
|||||||
export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload> {
|
export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload> {
|
||||||
const formData = await req.formData()
|
const formData = await req.formData()
|
||||||
const folderValue = formData.get('folderName')
|
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, '-')
|
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 gitModelMode = parseGitModelMode(formData.get('gitModelMode'))
|
||||||
|
|
||||||
const rawFiles = formData.getAll('files')
|
const rawFiles = formData.getAll('files')
|
||||||
|
|||||||
+61
-16
@@ -7,6 +7,7 @@ import { compressTextureBuffer } from '@/lib/texture-compression'
|
|||||||
import { classifyAssetCategory } from '@/lib/asset-classification'
|
import { classifyAssetCategory } from '@/lib/asset-classification'
|
||||||
import { normalizeTextureFilename } from '@/lib/asset-naming'
|
import { normalizeTextureFilename } from '@/lib/asset-naming'
|
||||||
import { TEXTURE_EXTENSIONS, TMP_DIR } from '@/lib/constants'
|
import { TEXTURE_EXTENSIONS, TMP_DIR } from '@/lib/constants'
|
||||||
|
import { getErrorMessage, isRecord } from '@/lib/guards'
|
||||||
import { getModelAssetPath } from '@/lib/model-paths'
|
import { getModelAssetPath } from '@/lib/model-paths'
|
||||||
import type { GitModelMode, ParsedFile, PreparedAssetSummary, PreparedGitAssetsResult, PushFile } from '@/lib/types'
|
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 }
|
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[]) {
|
function getTextureFilenameMap(parsedFiles: ParsedFile[]) {
|
||||||
const filenameMap = new Map<string, string>()
|
const filenameMap = new Map<string, string>()
|
||||||
const normalizedGroups = new Map<string, Array<{ original: string; normalized: string }>>()
|
const normalizedGroups = new Map<string, Array<{ original: string; normalized: string }>>()
|
||||||
@@ -75,7 +100,7 @@ function rewriteGltfUris(value: JsonValue, filenameMap: Map<string, string>): Js
|
|||||||
function prepareModelBuffer(buffer: Buffer, filenameMap: Map<string, string>) {
|
function prepareModelBuffer(buffer: Buffer, filenameMap: Map<string, string>) {
|
||||||
if (filenameMap.size === 0) return buffer
|
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')
|
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(
|
async function prepareDracoGlb(
|
||||||
@@ -160,22 +192,35 @@ async function prepareDracoGlb(
|
|||||||
await writeFile(join(tmpFolder, filename), content)
|
await writeFile(join(tmpFolder, filename), content)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await compressWithBlender(inputModelPath, outputModelPath)
|
try {
|
||||||
if (!result.success || !existsSync(outputModelPath)) {
|
const result = await compressWithBlender(inputModelPath, outputModelPath)
|
||||||
throw new Error(result.error || 'Compression Blender echouee')
|
if (!result.success || !existsSync(outputModelPath)) {
|
||||||
}
|
throw new Error(result.error || 'Compression Blender echouee')
|
||||||
|
}
|
||||||
|
|
||||||
const content = await readFile(outputModelPath)
|
const content = await readFile(outputModelPath)
|
||||||
const modelFilename = 'model.glb'
|
const modelFilename = 'model.glb'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filesToPush: [{
|
filesToPush: [{
|
||||||
path: getModelAssetPath(folderName, modelFilename),
|
path: getModelAssetPath(folderName, modelFilename),
|
||||||
contentBase64: content.toString('base64'),
|
contentBase64: content.toString('base64'),
|
||||||
}],
|
}],
|
||||||
modelFilename,
|
modelFilename,
|
||||||
assetSummaries: [{ filename: modelFilename, kind: 'model', compressed: true }],
|
assetSummaries: [{ filename: modelFilename, kind: 'model', compressed: true }],
|
||||||
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 {
|
} finally {
|
||||||
await rm(tmpFolder, { recursive: true, force: true }).catch(() => {})
|
await rm(tmpFolder, { recursive: true, force: true }).catch(() => {})
|
||||||
|
|||||||
+22
-1
@@ -15,11 +15,13 @@ export type DriveAction = 'new' | 'replace'
|
|||||||
|
|
||||||
export type FileChange = 'new' | 'changed' | 'unchanged'
|
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 type AssetCategory = 'color' | 'diffuse' | 'roughness' | 'normal' | 'metalness' | 'height' | 'opacity' | 'orm' | 'ao' | 'assets'
|
||||||
|
|
||||||
export interface FileDiff {
|
export interface FileDiff {
|
||||||
name: string
|
name: string
|
||||||
status: 'changed' | 'new' | 'deleted'
|
status: FileDiffStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteFile {
|
export interface RemoteFile {
|
||||||
@@ -40,11 +42,30 @@ export interface StagingUploadResult {
|
|||||||
filesCount: number
|
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 {
|
export interface PreparedGitAssetsResult {
|
||||||
filesToPush: PushFile[]
|
filesToPush: PushFile[]
|
||||||
modelFilename: string
|
modelFilename: string
|
||||||
assetSummaries: PreparedAssetSummary[]
|
assetSummaries: PreparedAssetSummary[]
|
||||||
compressed: boolean
|
compressed: boolean
|
||||||
|
deliveryMode: GitModelMode
|
||||||
compressionError?: string
|
compressionError?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+93
-45
@@ -1,16 +1,46 @@
|
|||||||
import { isRecord } from './guards'
|
import { getErrorMessage, isRecord } from './guards'
|
||||||
import type { FolderEntry } from './client-types'
|
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 {
|
interface CompressionWarningPayload {
|
||||||
exists: boolean
|
compressionError?: unknown
|
||||||
diffs: FileDiff[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function getApiError(data: unknown, fallback: string) {
|
||||||
return isRecord(data) && typeof data.error === 'string' ? data.error : fallback
|
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) {
|
function getUploadJsonHeaders(secret: string) {
|
||||||
return {
|
return {
|
||||||
'Content-Type': 'application/json',
|
'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) {
|
function isAbortError(err: unknown) {
|
||||||
return err instanceof DOMException && err.name === 'AbortError'
|
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 {
|
function isFileDiff(value: unknown): value is FileDiff {
|
||||||
return isRecord(value)
|
return isRecord(value)
|
||||||
&& typeof value.name === 'string'
|
&& typeof value.name === 'string'
|
||||||
@@ -50,27 +105,34 @@ export async function checkFolderDiffs(
|
|||||||
stagingId: string,
|
stagingId: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<CheckResult> {
|
): Promise<CheckUploadResult> {
|
||||||
const res = await fetch('/api/upload/check', {
|
const { res, data } = await postUploadJson('/api/upload/check', secret, { stagingId }, signal)
|
||||||
method: 'POST',
|
|
||||||
headers: getUploadJsonHeaders(secret),
|
|
||||||
body: JSON.stringify({ stagingId }),
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
|
|
||||||
const data: unknown = await res.json()
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(getApiError(data, `Erreur serveur (${res.status})`))
|
throw new Error(getApiError(data, `Erreur serveur (${res.status})`))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isRecord(data) || data.success !== true || data.exists !== true) {
|
if (!isSuccessfulUploadData(data)) {
|
||||||
return { exists: false, diffs: [] }
|
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) : []
|
const diffs = Array.isArray(data.diffs) ? data.diffs.filter(isFileDiff) : []
|
||||||
|
|
||||||
return { exists: true, diffs }
|
return {
|
||||||
|
exists: true,
|
||||||
|
diffs,
|
||||||
|
warning,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stageUpload(
|
export async function stageUpload(
|
||||||
@@ -89,7 +151,7 @@ export async function stageUpload(
|
|||||||
|
|
||||||
const data: unknown = await res.json()
|
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})`))
|
throw new Error(getApiError(data, `Erreur serveur (${res.status})`))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,24 +171,15 @@ export async function uploadDrive(
|
|||||||
secret: string,
|
secret: string,
|
||||||
action: DriveAction,
|
action: DriveAction,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<DriveUploadResult> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/upload/drive', {
|
const { res, data } = await postUploadJson('/api/upload/drive', secret, { stagingId, action }, signal)
|
||||||
method: 'POST',
|
if (!res.ok || !isSuccessfulUploadData(data)) {
|
||||||
headers: getUploadJsonHeaders(secret),
|
|
||||||
body: JSON.stringify({ stagingId, action }),
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
const data: unknown = await res.json()
|
|
||||||
if (!res.ok || !isRecord(data) || data.success !== true) {
|
|
||||||
return { success: false, error: getApiError(data, `Erreur serveur (${res.status})`) }
|
return { success: false, error: getApiError(data, `Erreur serveur (${res.status})`) }
|
||||||
}
|
}
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isAbortError(err)) {
|
return { success: false, error: getNetworkUploadError(err, 'Erreur Drive') }
|
||||||
return { success: false, error: 'Upload annule' }
|
|
||||||
}
|
|
||||||
return { success: false, error: 'Erreur reseau (Drive)' }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,30 +188,25 @@ export async function uploadGit(
|
|||||||
secret: string,
|
secret: string,
|
||||||
onProgress: (pct: number) => void,
|
onProgress: (pct: number) => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<{ success: boolean; filename?: string; error?: string }> {
|
): Promise<GitUploadResult> {
|
||||||
onProgress(10)
|
onProgress(10)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/upload/git', {
|
const { res, data } = await postUploadJson('/api/upload/git', secret, { stagingId }, signal)
|
||||||
method: 'POST',
|
|
||||||
headers: getUploadJsonHeaders(secret),
|
|
||||||
body: JSON.stringify({ stagingId }),
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
|
|
||||||
onProgress(80)
|
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})`) }
|
return { success: false, error: getApiError(data, `Erreur serveur (${res.status})`) }
|
||||||
}
|
}
|
||||||
|
|
||||||
onProgress(100)
|
onProgress(100)
|
||||||
return { success: true, filename: typeof data.folderName === 'string' ? data.folderName : undefined }
|
return {
|
||||||
} catch (err) {
|
success: true,
|
||||||
if (isAbortError(err)) {
|
filename: typeof data.folderName === 'string' ? data.folderName : undefined,
|
||||||
return { success: false, error: 'Upload annule' }
|
warning: getCompressionWarning(data),
|
||||||
}
|
}
|
||||||
return { success: false, error: 'Erreur reseau' }
|
} catch (err) {
|
||||||
|
return { success: false, error: getNetworkUploadError(err, 'Erreur GitHub') }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-3
@@ -1,4 +1,5 @@
|
|||||||
import { isRecord } from './guards'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getErrorMessage, isRecord } from './guards'
|
||||||
import type { DriveAction } from './types'
|
import type { DriveAction } from './types'
|
||||||
|
|
||||||
interface StagingRequestBody {
|
interface StagingRequestBody {
|
||||||
@@ -9,6 +10,21 @@ interface DriveRequestBody extends StagingRequestBody {
|
|||||||
action: DriveAction
|
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 {
|
export function parseStagingRequestBody(value: unknown): StagingRequestBody {
|
||||||
if (!isRecord(value) || typeof value.stagingId !== 'string' || value.stagingId.trim() === '') {
|
if (!isRecord(value) || typeof value.stagingId !== 'string' || value.stagingId.trim() === '') {
|
||||||
throw new Error('stagingId manquant')
|
throw new Error('stagingId manquant')
|
||||||
@@ -17,9 +33,22 @@ export function parseStagingRequestBody(value: unknown): StagingRequestBody {
|
|||||||
return { stagingId: value.stagingId }
|
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 {
|
export function parseDriveRequestBody(value: unknown): DriveRequestBody {
|
||||||
const { stagingId } = parseStagingRequestBody(value)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-1
@@ -3,9 +3,11 @@ import { dirname, join } from 'path'
|
|||||||
import { mkdir, readdir, readFile, rm, writeFile } from 'fs/promises'
|
import { mkdir, readdir, readFile, rm, writeFile } from 'fs/promises'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { TMP_DIR } from '@/lib/constants'
|
import { TMP_DIR } from '@/lib/constants'
|
||||||
|
import { isRecord } from '@/lib/guards'
|
||||||
import { getModelAssetPath } from '@/lib/model-paths'
|
import { getModelAssetPath } from '@/lib/model-paths'
|
||||||
import { prepareGitAssets } from '@/lib/prepare-git-assets'
|
import { prepareGitAssets } from '@/lib/prepare-git-assets'
|
||||||
import type {
|
import type {
|
||||||
|
AssetCategory,
|
||||||
GitModelMode,
|
GitModelMode,
|
||||||
ParsedFile,
|
ParsedFile,
|
||||||
PreparedAssetSummary,
|
PreparedAssetSummary,
|
||||||
@@ -26,6 +28,7 @@ interface StagedOriginalFile {
|
|||||||
interface StagedPreparedData {
|
interface StagedPreparedData {
|
||||||
modelFilename: string
|
modelFilename: string
|
||||||
compressed: boolean
|
compressed: boolean
|
||||||
|
deliveryMode?: GitModelMode
|
||||||
compressionError?: string
|
compressionError?: string
|
||||||
assetSummaries: PreparedAssetSummary[]
|
assetSummaries: PreparedAssetSummary[]
|
||||||
}
|
}
|
||||||
@@ -55,6 +58,69 @@ function getManifestPath(stagingId: string) {
|
|||||||
return join(getStageDir(stagingId), 'manifest.json')
|
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) {
|
async function ensureParentDir(filePath: string) {
|
||||||
await mkdir(dirname(filePath), { recursive: true })
|
await mkdir(dirname(filePath), { recursive: true })
|
||||||
}
|
}
|
||||||
@@ -126,7 +192,7 @@ export async function createStagingUpload(
|
|||||||
export async function readStagedManifest(stagingId: string): Promise<StagingManifest> {
|
export async function readStagedManifest(stagingId: string): Promise<StagingManifest> {
|
||||||
const manifestPath = getManifestPath(stagingId)
|
const manifestPath = getManifestPath(stagingId)
|
||||||
const content = await readFile(manifestPath, 'utf-8')
|
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[]> {
|
async function readOriginalParsedFiles(stagingId: string, manifest: StagingManifest): Promise<ParsedFile[]> {
|
||||||
@@ -179,6 +245,7 @@ export async function ensurePreparedStagingAssets(stagingId: string): Promise<Pr
|
|||||||
manifest.prepared = {
|
manifest.prepared = {
|
||||||
modelFilename: prepared.modelFilename,
|
modelFilename: prepared.modelFilename,
|
||||||
compressed: prepared.compressed,
|
compressed: prepared.compressed,
|
||||||
|
deliveryMode: prepared.deliveryMode,
|
||||||
compressionError: prepared.compressionError,
|
compressionError: prepared.compressionError,
|
||||||
assetSummaries: prepared.assetSummaries,
|
assetSummaries: prepared.assetSummaries,
|
||||||
}
|
}
|
||||||
@@ -192,6 +259,7 @@ export async function ensurePreparedStagingAssets(stagingId: string): Promise<Pr
|
|||||||
modelFilename: manifest.prepared.modelFilename,
|
modelFilename: manifest.prepared.modelFilename,
|
||||||
assetSummaries: manifest.prepared.assetSummaries,
|
assetSummaries: manifest.prepared.assetSummaries,
|
||||||
compressed: manifest.prepared.compressed,
|
compressed: manifest.prepared.compressed,
|
||||||
|
deliveryMode: manifest.prepared.deliveryMode ?? manifest.gitModelMode,
|
||||||
compressionError: manifest.prepared.compressionError,
|
compressionError: manifest.prepared.compressionError,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,22 +6,25 @@ import type { TextureFile } from '@/lib/client-types'
|
|||||||
const SUPPORT_FILE_EXT_ARRAY = [...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS]
|
const SUPPORT_FILE_EXT_ARRAY = [...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS]
|
||||||
|
|
||||||
interface GltfBufferReference {
|
interface GltfBufferReference {
|
||||||
uri?: unknown
|
uri?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GltfJson {
|
interface GltfJson {
|
||||||
buffers?: GltfBufferReference[]
|
buffers?: GltfBufferReference[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Discriminated union: either valid (with model) or invalid (with errors). */
|
|
||||||
type ValidationResult =
|
type ValidationResult =
|
||||||
| { ok: true; model: File; textures: TextureFile[]; warnings: string[] }
|
| { ok: true; model: File; textures: TextureFile[]; warnings: string[] }
|
||||||
| { ok: false; errors: 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 {
|
function isGltfJson(value: unknown): value is GltfJson {
|
||||||
if (!isRecord(value)) return false
|
if (!isRecord(value)) return false
|
||||||
if (value.buffers === undefined) return true
|
if (value.buffers === undefined) return true
|
||||||
return Array.isArray(value.buffers) && value.buffers.every(isRecord)
|
return Array.isArray(value.buffers) && value.buffers.every(isGltfBufferReference)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReferencedBufferNames(gltf: GltfJson) {
|
function getReferencedBufferNames(gltf: GltfJson) {
|
||||||
|
|||||||
Reference in New Issue
Block a user