refactor: clean upload pipeline and restore draco delivery
This commit is contained in:
@@ -1,46 +1,9 @@
|
||||
import { getAssetFamily } from './asset-naming'
|
||||
|
||||
export type AssetCategory = 'color' | 'diffuse' | 'roughness' | 'normal' | 'metalness' | 'height' | 'opacity' | 'orm' | 'ao' | 'assets'
|
||||
import type { AssetCategory } from './types'
|
||||
|
||||
export function classifyAssetCategory(filename: string): AssetCategory {
|
||||
const name = filename.replace(/\.[^.]+$/, '')
|
||||
const family = getAssetFamily(name.split('_')[0])
|
||||
|
||||
if (family === 'color') {
|
||||
return 'color'
|
||||
}
|
||||
|
||||
if (family === 'diffuse') {
|
||||
return 'diffuse'
|
||||
}
|
||||
|
||||
if (family === 'roughness') {
|
||||
return 'roughness'
|
||||
}
|
||||
|
||||
if (family === 'normal') {
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
if (family === 'metalness') {
|
||||
return 'metalness'
|
||||
}
|
||||
|
||||
if (family === 'height') {
|
||||
return 'height'
|
||||
}
|
||||
|
||||
if (family === 'opacity') {
|
||||
return 'opacity'
|
||||
}
|
||||
|
||||
if (family === 'orm') {
|
||||
return 'orm'
|
||||
}
|
||||
|
||||
if (family === 'ao') {
|
||||
return 'ao'
|
||||
}
|
||||
|
||||
return 'assets'
|
||||
return family || 'assets'
|
||||
}
|
||||
|
||||
+3
-3
@@ -10,7 +10,7 @@ export const ASSET_FAMILIES = [
|
||||
'ao',
|
||||
] as const
|
||||
|
||||
export type AssetFamily = typeof ASSET_FAMILIES[number]
|
||||
type AssetFamily = typeof ASSET_FAMILIES[number]
|
||||
|
||||
const ASSET_FAMILY_BY_KEY = new Map(ASSET_FAMILIES.map((family) => [family.toLowerCase(), family]))
|
||||
const FORBIDDEN_ASSET_FAMILY_ALIASES: ReadonlyMap<string, AssetFamily> = new Map([
|
||||
@@ -44,7 +44,7 @@ export function getAssetFamily(value: string): AssetFamily | undefined {
|
||||
return ASSET_FAMILY_BY_KEY.get(value.toLowerCase())
|
||||
}
|
||||
|
||||
export function getForbiddenAssetFamilyAlias(value: string): AssetFamily | undefined {
|
||||
function getForbiddenAssetFamilyAlias(value: string): AssetFamily | undefined {
|
||||
return FORBIDDEN_ASSET_FAMILY_ALIASES.get(value.toLowerCase())
|
||||
}
|
||||
|
||||
@@ -143,6 +143,6 @@ export function getTextureNamingError(filename: string) {
|
||||
return `Asset inconnu : ${filename}. Familles autorisees : ${formatAssetFamilies()}. Utilisez asset.png pour tout le modele ou asset_objet.png pour cibler un objet.`
|
||||
}
|
||||
|
||||
export function formatAssetFamilies() {
|
||||
function formatAssetFamilies() {
|
||||
return ASSET_FAMILIES.join(', ')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { join } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
/**
|
||||
* Compress a GLTF/GLB model using Blender's Draco compression.
|
||||
* Returns { success: true } on success, or { success: false, error } on failure.
|
||||
* GLB Draco is explicit: callers should fail instead of silently pushing heavy assets.
|
||||
*/
|
||||
export async function compressWithBlender(
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const blenderPath = process.env.BLENDER_PATH || 'blender'
|
||||
const timeout = Number(process.env.BLENDER_TIMEOUT_MS || 600_000)
|
||||
const scriptPath = join(process.cwd(), 'scripts', 'compress.py')
|
||||
|
||||
if (!existsSync(scriptPath)) {
|
||||
return { success: false, error: 'scripts/compress.py introuvable' }
|
||||
}
|
||||
|
||||
try {
|
||||
await execFileAsync(
|
||||
blenderPath,
|
||||
[
|
||||
'--background',
|
||||
'--python', scriptPath,
|
||||
'--',
|
||||
'-i', inputPath,
|
||||
'-o', outputPath,
|
||||
'--draco-level', '7',
|
||||
'--texture-size', '512',
|
||||
'-q',
|
||||
],
|
||||
{ timeout },
|
||||
)
|
||||
|
||||
if (!existsSync(outputPath)) {
|
||||
return { success: false, error: "Blender n'a pas produit de fichier compresse" }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return { success: false, error: `Compression Blender echouee: ${message}` }
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,6 @@ export function revokeEntryUrls(entry: FolderEntry) {
|
||||
const urls = new Set<string>()
|
||||
|
||||
if (entry.modelUrl) urls.add(entry.modelUrl)
|
||||
Object.values(entry.assetUrls || {}).forEach((url) => urls.add(url))
|
||||
Object.values(entry.assetUrls).forEach((url) => urls.add(url))
|
||||
urls.forEach((url) => URL.revokeObjectURL(url))
|
||||
}
|
||||
|
||||
+1
-5
@@ -1,7 +1,3 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client-side types — used by components and hooks (no Node.js Buffer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type FileStatus = 'pending' | 'uploading' | 'success' | 'error'
|
||||
|
||||
export interface TextureFile {
|
||||
@@ -20,7 +16,7 @@ export interface FolderEntry {
|
||||
error?: string
|
||||
filename?: string
|
||||
modelUrl?: string
|
||||
assetUrls?: Record<string, string>
|
||||
assetUrls: Record<string, string>
|
||||
viewerOpen?: boolean
|
||||
warnings: string[]
|
||||
driveStatus?: DriveStatus
|
||||
|
||||
+31
-14
@@ -1,6 +1,23 @@
|
||||
import type { AssetCategory } from './asset-classification'
|
||||
import type { FileChange } from './types'
|
||||
import type { PreparedAssetSummary } from './types'
|
||||
import { ASSET_FAMILIES } from './asset-naming'
|
||||
import type { AssetCategory, FileChange, PreparedAssetSummary } from './types'
|
||||
|
||||
const ASSET_SECTION_ORDER: AssetCategory[] = [...ASSET_FAMILIES, 'assets']
|
||||
|
||||
function getChangePrefix(change: FileChange) {
|
||||
if (change === 'new') return '✅'
|
||||
if (change === 'changed') return '🔄'
|
||||
return null
|
||||
}
|
||||
|
||||
function addGroupedAssetLine(
|
||||
grouped: Map<AssetCategory, string[]>,
|
||||
category: AssetCategory,
|
||||
line: string,
|
||||
) {
|
||||
const current = grouped.get(category) || []
|
||||
current.push(line)
|
||||
grouped.set(category, current)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a formatted commit message based on the upload context.
|
||||
@@ -25,7 +42,6 @@ export function buildCommitMessage(
|
||||
|
||||
const lines: string[] = [title, '']
|
||||
|
||||
// Model section — show status for new, changed, or unchanged
|
||||
const modelSummary = assetSummaries.find((asset) => asset.kind === 'model')
|
||||
const modelChange = fileChanges.get(modelFilename.toLowerCase())
|
||||
if (modelChange === 'new') {
|
||||
@@ -45,15 +61,16 @@ export function buildCommitMessage(
|
||||
if (asset.kind === 'model' || !asset.category) continue
|
||||
|
||||
const change = fileChanges.get(asset.filename.toLowerCase())
|
||||
if (change === 'new') {
|
||||
const current = grouped.get(asset.category) || []
|
||||
current.push(` ✅ ${asset.filename}${asset.compressed ? ' (compressed)' : ''}`)
|
||||
grouped.set(asset.category, current)
|
||||
} else if (change === 'changed') {
|
||||
const current = grouped.get(asset.category) || []
|
||||
current.push(` 🔄 ${asset.filename}${asset.compressed ? ' (compressed)' : ''}`)
|
||||
grouped.set(asset.category, current)
|
||||
}
|
||||
if (!change) continue
|
||||
|
||||
const prefix = getChangePrefix(change)
|
||||
if (!prefix) continue
|
||||
|
||||
addGroupedAssetLine(
|
||||
grouped,
|
||||
asset.category,
|
||||
` ${prefix} ${asset.filename}${asset.compressed ? ' (compressed)' : ''}`,
|
||||
)
|
||||
}
|
||||
|
||||
const sectionTitles: Record<AssetCategory, string> = {
|
||||
@@ -69,7 +86,7 @@ export function buildCommitMessage(
|
||||
assets: '🧩 Assets',
|
||||
}
|
||||
|
||||
for (const category of ['color', 'diffuse', 'roughness', 'normal', 'metalness', 'height', 'opacity', 'orm', 'ao', 'assets'] as const) {
|
||||
for (const category of ASSET_SECTION_ORDER) {
|
||||
const entries = grouped.get(category)
|
||||
if (!entries || entries.length === 0) continue
|
||||
lines.push('')
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ export const ASSET_EXTENSIONS = new Set(['.bin'])
|
||||
export const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS])
|
||||
|
||||
/** Extensions tracked by Git LFS (must match .gitattributes) */
|
||||
export const LFS_EXTENSIONS = new Set(['.gltf', '.bin', '.png', '.jpg', '.jpeg', '.webp'])
|
||||
export const LFS_EXTENSIONS = new Set(['.glb', '.gltf', '.bin', '.png', '.jpg', '.jpeg', '.webp'])
|
||||
|
||||
export const TMP_DIR = '/tmp/assets'
|
||||
|
||||
|
||||
+1
-3
@@ -13,9 +13,7 @@ interface DiffResult {
|
||||
* the remote file map.
|
||||
*
|
||||
* Rules:
|
||||
* - Models: always re-pushed,
|
||||
* but marked as 'unchanged' in the commit message when the folder already
|
||||
* exists (we keep the current behavior of always delivering the model file).
|
||||
* - Models: always re-pushed, but marked as unchanged when the remote file exists.
|
||||
* - Textures: compared by size (not compressed, reliable).
|
||||
* - Orphan remote files: classified as deletions.
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Format bytes to human-readable string
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes <= 0) return '0 B'
|
||||
const k = 1024
|
||||
|
||||
+4
-2
@@ -6,8 +6,10 @@ import type { PushFile, RemoteFile } from './types'
|
||||
|
||||
const LFS_BATCH_SIZE = 100
|
||||
|
||||
type LogDetails = Record<string, string | number | boolean | undefined>
|
||||
|
||||
function isHttpError(err: unknown): err is { status: number } {
|
||||
return typeof err === 'object' && err !== null && 'status' in err && typeof (err as Record<string, unknown>).status === 'number'
|
||||
return isRecord(err) && typeof err.status === 'number'
|
||||
}
|
||||
|
||||
function getOctokit(): Octokit {
|
||||
@@ -51,7 +53,7 @@ function formatElapsed(startedAt: number) {
|
||||
return `${((performance.now() - startedAt) / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
function logInfo(step: string, action: string, startedAt: number, details?: Record<string, unknown>) {
|
||||
function logInfo(step: string, action: string, startedAt: number, details?: LogDetails) {
|
||||
console.info(`[INFO] ${step} -> ${action} | Timer: ${formatElapsed(startedAt)}`, details || '')
|
||||
}
|
||||
|
||||
|
||||
+9
-2
@@ -3,11 +3,17 @@ import { NextRequest } from 'next/server'
|
||||
import { sanitizeFilename } from './sanitize'
|
||||
import { ALL_ALLOWED_EXTENSIONS, MODEL_EXTENSIONS, MAX_FILE_SIZE, TEXTURE_EXTENSIONS } from './constants'
|
||||
import { getTextureNamingError } from './asset-naming'
|
||||
import type { ParsedFile } from './types'
|
||||
import type { GitModelMode, ParsedFile } from './types'
|
||||
|
||||
interface ParsedUpload {
|
||||
folderName: string
|
||||
files: ParsedFile[]
|
||||
gitModelMode: GitModelMode
|
||||
}
|
||||
|
||||
function parseGitModelMode(value: FormDataEntryValue | null): GitModelMode {
|
||||
if (value === 'draco-glb' || value === 'keep-gltf') return value
|
||||
throw new Error('Mode Git invalide')
|
||||
}
|
||||
|
||||
export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload> {
|
||||
@@ -15,6 +21,7 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
|
||||
const folderValue = formData.get('folderName')
|
||||
const folderName = typeof folderValue === 'string' ? folderValue.trim() || 'assets' : 'assets'
|
||||
const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-')
|
||||
const gitModelMode = parseGitModelMode(formData.get('gitModelMode'))
|
||||
|
||||
const rawFiles = formData.getAll('files')
|
||||
const fileTypes = formData.getAll('fileTypes').filter((value): value is string => typeof value === 'string')
|
||||
@@ -95,5 +102,5 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
|
||||
throw new Error('Un seul fichier model.gltf est autorise')
|
||||
}
|
||||
|
||||
return { folderName: safeFolderName, files: parsed }
|
||||
return { folderName: safeFolderName, files: parsed, gitModelMode }
|
||||
}
|
||||
|
||||
+80
-24
@@ -1,23 +1,22 @@
|
||||
import { extname } from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { existsSync } from 'fs'
|
||||
import { mkdir, readFile, rm, writeFile } from 'fs/promises'
|
||||
import { extname, join } from 'path'
|
||||
import { compressWithBlender } from '@/lib/blender'
|
||||
import { compressTextureBuffer } from '@/lib/texture-compression'
|
||||
import { classifyAssetCategory } from '@/lib/asset-classification'
|
||||
import { normalizeTextureFilename } from '@/lib/asset-naming'
|
||||
import { TEXTURE_EXTENSIONS } from '@/lib/constants'
|
||||
import { TEXTURE_EXTENSIONS, TMP_DIR } from '@/lib/constants'
|
||||
import { getModelAssetPath } from '@/lib/model-paths'
|
||||
import type { ParsedFile, PreparedAssetSummary, PushFile } from '@/lib/types'
|
||||
import type { GitModelMode, ParsedFile, PreparedAssetSummary, PreparedGitAssetsResult, PushFile } from '@/lib/types'
|
||||
|
||||
interface PrepareGitAssetsParams {
|
||||
folderName: string
|
||||
parsedFiles: ParsedFile[]
|
||||
gitModelMode: GitModelMode
|
||||
}
|
||||
|
||||
interface PrepareGitAssetsResult {
|
||||
filesToPush: PushFile[]
|
||||
modelFilename: string
|
||||
assetSummaries: PreparedAssetSummary[]
|
||||
compressed: boolean
|
||||
compressionError?: string
|
||||
}
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
|
||||
|
||||
function getTextureFilenameMap(parsedFiles: ParsedFile[]) {
|
||||
const filenameMap = new Map<string, string>()
|
||||
@@ -51,14 +50,14 @@ function getReferencedFilename(uri: string) {
|
||||
return cleanUri.split(/[\\/]/).pop()?.toLowerCase()
|
||||
}
|
||||
|
||||
function rewriteGltfUris(value: unknown, filenameMap: Map<string, string>): unknown {
|
||||
function rewriteGltfUris(value: JsonValue, filenameMap: Map<string, string>): JsonValue {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => rewriteGltfUris(entry, filenameMap))
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') return value
|
||||
|
||||
const rewritten: Record<string, unknown> = {}
|
||||
const rewritten: Record<string, JsonValue> = {}
|
||||
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (key === 'uri' && typeof entry === 'string') {
|
||||
@@ -76,20 +75,20 @@ function rewriteGltfUris(value: unknown, filenameMap: Map<string, string>): unkn
|
||||
function prepareModelBuffer(buffer: Buffer, filenameMap: Map<string, string>) {
|
||||
if (filenameMap.size === 0) return buffer
|
||||
|
||||
const parsed: unknown = JSON.parse(buffer.toString('utf-8'))
|
||||
const parsed = JSON.parse(buffer.toString('utf-8')) as JsonValue
|
||||
return Buffer.from(JSON.stringify(rewriteGltfUris(parsed, filenameMap), null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
export async function prepareGitAssets({
|
||||
folderName,
|
||||
parsedFiles,
|
||||
}: PrepareGitAssetsParams): Promise<PrepareGitAssetsResult> {
|
||||
async function prepareSeparateFiles(
|
||||
folderName: string,
|
||||
parsedFiles: ParsedFile[],
|
||||
textureFilenameMap: Map<string, string>,
|
||||
) {
|
||||
const filesToPush: PushFile[] = []
|
||||
const assetSummaries: PreparedAssetSummary[] = []
|
||||
let modelFilename = ''
|
||||
let compressed = false
|
||||
let compressionError: string | undefined
|
||||
const textureFilenameMap = getTextureFilenameMap(parsedFiles)
|
||||
|
||||
for (const pf of parsedFiles) {
|
||||
let content = pf.buffer
|
||||
@@ -131,11 +130,68 @@ export async function prepareGitAssets({
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
filesToPush,
|
||||
modelFilename,
|
||||
assetSummaries,
|
||||
compressed,
|
||||
compressionError,
|
||||
return { filesToPush, modelFilename, assetSummaries, compressed, compressionError }
|
||||
}
|
||||
|
||||
async function prepareDracoGlb(
|
||||
folderName: string,
|
||||
parsedFiles: ParsedFile[],
|
||||
textureFilenameMap: Map<string, string>,
|
||||
): Promise<PreparedGitAssetsResult> {
|
||||
const tmpFolder = join(TMP_DIR, 'blender', `${folderName}-${randomUUID()}`)
|
||||
const inputModelPath = join(tmpFolder, 'model.gltf')
|
||||
const outputModelPath = join(tmpFolder, 'model.glb')
|
||||
|
||||
await mkdir(tmpFolder, { recursive: true })
|
||||
|
||||
try {
|
||||
for (const pf of parsedFiles) {
|
||||
if (pf.isModel) {
|
||||
await writeFile(inputModelPath, prepareModelBuffer(pf.buffer, textureFilenameMap))
|
||||
continue
|
||||
}
|
||||
|
||||
const filename = textureFilenameMap.get(pf.filename.toLowerCase()) || pf.filename
|
||||
const ext = extname(filename).toLowerCase()
|
||||
const content = TEXTURE_EXTENSIONS.has(ext)
|
||||
? (await compressTextureBuffer(filename, pf.buffer)).buffer
|
||||
: pf.buffer
|
||||
|
||||
await writeFile(join(tmpFolder, filename), content)
|
||||
}
|
||||
|
||||
const result = await compressWithBlender(inputModelPath, outputModelPath)
|
||||
if (!result.success || !existsSync(outputModelPath)) {
|
||||
throw new Error(result.error || 'Compression Blender echouee')
|
||||
}
|
||||
|
||||
const content = await readFile(outputModelPath)
|
||||
const modelFilename = 'model.glb'
|
||||
|
||||
return {
|
||||
filesToPush: [{
|
||||
path: getModelAssetPath(folderName, modelFilename),
|
||||
contentBase64: content.toString('base64'),
|
||||
}],
|
||||
modelFilename,
|
||||
assetSummaries: [{ filename: modelFilename, kind: 'model', compressed: true }],
|
||||
compressed: true,
|
||||
}
|
||||
} finally {
|
||||
await rm(tmpFolder, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareGitAssets({
|
||||
folderName,
|
||||
parsedFiles,
|
||||
gitModelMode,
|
||||
}: PrepareGitAssetsParams): Promise<PreparedGitAssetsResult> {
|
||||
const textureFilenameMap = getTextureFilenameMap(parsedFiles)
|
||||
|
||||
if (gitModelMode === 'keep-gltf') {
|
||||
return prepareSeparateFiles(folderName, parsedFiles, textureFilenameMap)
|
||||
}
|
||||
|
||||
return prepareDracoGlb(folderName, parsedFiles, textureFilenameMap)
|
||||
}
|
||||
|
||||
+24
-2
@@ -1,5 +1,3 @@
|
||||
import type { AssetCategory } from './asset-classification'
|
||||
|
||||
export interface ParsedFile {
|
||||
filename: string
|
||||
buffer: Buffer
|
||||
@@ -11,8 +9,14 @@ export interface PushFile {
|
||||
contentBase64: string
|
||||
}
|
||||
|
||||
export type GitModelMode = 'draco-glb' | 'keep-gltf'
|
||||
|
||||
export type DriveAction = 'new' | 'replace'
|
||||
|
||||
export type FileChange = 'new' | 'changed' | 'unchanged'
|
||||
|
||||
export type AssetCategory = 'color' | 'diffuse' | 'roughness' | 'normal' | 'metalness' | 'height' | 'opacity' | 'orm' | 'ao' | 'assets'
|
||||
|
||||
export interface FileDiff {
|
||||
name: string
|
||||
status: 'changed' | 'new' | 'deleted'
|
||||
@@ -29,3 +33,21 @@ export interface PreparedAssetSummary {
|
||||
category?: AssetCategory
|
||||
compressed: boolean
|
||||
}
|
||||
|
||||
export interface StagingUploadResult {
|
||||
stagingId: string
|
||||
folderName: string
|
||||
filesCount: number
|
||||
}
|
||||
|
||||
export interface PreparedGitAssetsResult {
|
||||
filesToPush: PushFile[]
|
||||
modelFilename: string
|
||||
assetSummaries: PreparedAssetSummary[]
|
||||
compressed: boolean
|
||||
compressionError?: string
|
||||
}
|
||||
|
||||
export interface PreparedStageAssetsResult extends PreparedGitAssetsResult {
|
||||
folderName: string
|
||||
}
|
||||
|
||||
+23
-25
@@ -1,31 +1,37 @@
|
||||
import { isRecord } from './guards'
|
||||
import type { FolderEntry } from './client-types'
|
||||
import type { FileDiff } from './types'
|
||||
import type { DriveAction, FileDiff, GitModelMode, StagingUploadResult } from './types'
|
||||
|
||||
export interface CheckResult {
|
||||
exists: boolean
|
||||
diffs: FileDiff[]
|
||||
}
|
||||
|
||||
interface StageResult {
|
||||
stagingId: string
|
||||
folderName: string
|
||||
filesCount: number
|
||||
}
|
||||
|
||||
function getApiError(data: unknown, fallback: string) {
|
||||
return isRecord(data) && typeof data.error === 'string' ? data.error : fallback
|
||||
}
|
||||
|
||||
function getUploadJsonHeaders(secret: string) {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'x-upload-secret': secret.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function isAbortError(err: unknown) {
|
||||
return err instanceof DOMException && err.name === 'AbortError'
|
||||
}
|
||||
|
||||
function isFileDiff(value: unknown): value is FileDiff {
|
||||
return isRecord(value)
|
||||
&& typeof value.name === 'string'
|
||||
&& (value.status === 'new' || value.status === 'changed' || value.status === 'deleted')
|
||||
}
|
||||
|
||||
function buildUploadFormData(folder: FolderEntry): FormData {
|
||||
function buildUploadFormData(folder: FolderEntry, gitModelMode: GitModelMode): FormData {
|
||||
const formData = new FormData()
|
||||
formData.append('folderName', folder.folderName)
|
||||
formData.append('gitModelMode', gitModelMode)
|
||||
|
||||
formData.append('files', folder.modelFile)
|
||||
formData.append('fileTypes', 'model')
|
||||
@@ -47,10 +53,7 @@ export async function checkFolderDiffs(
|
||||
): Promise<CheckResult> {
|
||||
const res = await fetch('/api/upload/check', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-upload-secret': secret.trim(),
|
||||
},
|
||||
headers: getUploadJsonHeaders(secret),
|
||||
body: JSON.stringify({ stagingId }),
|
||||
signal,
|
||||
})
|
||||
@@ -72,10 +75,11 @@ export async function checkFolderDiffs(
|
||||
|
||||
export async function stageUpload(
|
||||
folder: FolderEntry,
|
||||
gitModelMode: GitModelMode,
|
||||
secret: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<StageResult> {
|
||||
const formData = buildUploadFormData(folder)
|
||||
): Promise<StagingUploadResult> {
|
||||
const formData = buildUploadFormData(folder, gitModelMode)
|
||||
const res = await fetch('/api/upload/stage', {
|
||||
method: 'POST',
|
||||
headers: { 'x-upload-secret': secret.trim() },
|
||||
@@ -103,16 +107,13 @@ export async function stageUpload(
|
||||
export async function uploadDrive(
|
||||
stagingId: string,
|
||||
secret: string,
|
||||
action: 'new' | 'replace',
|
||||
action: DriveAction,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const res = await fetch('/api/upload/drive', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-upload-secret': secret.trim(),
|
||||
},
|
||||
headers: getUploadJsonHeaders(secret),
|
||||
body: JSON.stringify({ stagingId, action }),
|
||||
signal,
|
||||
})
|
||||
@@ -122,7 +123,7 @@ export async function uploadDrive(
|
||||
}
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
if (isAbortError(err)) {
|
||||
return { success: false, error: 'Upload annule' }
|
||||
}
|
||||
return { success: false, error: 'Erreur reseau (Drive)' }
|
||||
@@ -140,10 +141,7 @@ export async function uploadGit(
|
||||
try {
|
||||
const res = await fetch('/api/upload/git', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-upload-secret': secret.trim(),
|
||||
},
|
||||
headers: getUploadJsonHeaders(secret),
|
||||
body: JSON.stringify({ stagingId }),
|
||||
signal,
|
||||
})
|
||||
@@ -158,7 +156,7 @@ export async function uploadGit(
|
||||
onProgress(100)
|
||||
return { success: true, filename: typeof data.folderName === 'string' ? data.folderName : undefined }
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
if (isAbortError(err)) {
|
||||
return { success: false, error: 'Upload annule' }
|
||||
}
|
||||
return { success: false, error: 'Erreur reseau' }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { isRecord } from './guards'
|
||||
|
||||
type DriveAction = 'new' | 'replace'
|
||||
import type { DriveAction } from './types'
|
||||
|
||||
interface StagingRequestBody {
|
||||
stagingId: string
|
||||
|
||||
+23
-16
@@ -5,7 +5,14 @@ import { existsSync } from 'fs'
|
||||
import { TMP_DIR } from '@/lib/constants'
|
||||
import { getModelAssetPath } from '@/lib/model-paths'
|
||||
import { prepareGitAssets } from '@/lib/prepare-git-assets'
|
||||
import type { ParsedFile, PreparedAssetSummary, PushFile } from '@/lib/types'
|
||||
import type {
|
||||
GitModelMode,
|
||||
ParsedFile,
|
||||
PreparedAssetSummary,
|
||||
PreparedStageAssetsResult,
|
||||
PushFile,
|
||||
StagingUploadResult,
|
||||
} from '@/lib/types'
|
||||
|
||||
const STAGING_ROOT = join(TMP_DIR, 'staging')
|
||||
const STAGING_TTL_MS = 60 * 60 * 1000
|
||||
@@ -26,20 +33,12 @@ interface StagedPreparedData {
|
||||
interface StagingManifest {
|
||||
stagingId: string
|
||||
folderName: string
|
||||
gitModelMode: GitModelMode
|
||||
createdAt: number
|
||||
originals: StagedOriginalFile[]
|
||||
prepared?: StagedPreparedData
|
||||
}
|
||||
|
||||
interface PreparedStageAssetsResult {
|
||||
folderName: string
|
||||
filesToPush: PushFile[]
|
||||
modelFilename: string
|
||||
assetSummaries: PreparedAssetSummary[]
|
||||
compressed: boolean
|
||||
compressionError?: string
|
||||
}
|
||||
|
||||
function getStageDir(stagingId: string) {
|
||||
return join(STAGING_ROOT, stagingId)
|
||||
}
|
||||
@@ -87,7 +86,11 @@ async function cleanupExpiredStagingUploads() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function createStagingUpload(folderName: string, parsedFiles: ParsedFile[]) {
|
||||
export async function createStagingUpload(
|
||||
folderName: string,
|
||||
parsedFiles: ParsedFile[],
|
||||
gitModelMode: GitModelMode,
|
||||
): Promise<StagingUploadResult> {
|
||||
await cleanupExpiredStagingUploads()
|
||||
|
||||
const stagingId = randomUUID()
|
||||
@@ -106,6 +109,7 @@ export async function createStagingUpload(folderName: string, parsedFiles: Parse
|
||||
const manifest: StagingManifest = {
|
||||
stagingId,
|
||||
folderName,
|
||||
gitModelMode,
|
||||
createdAt: Date.now(),
|
||||
originals,
|
||||
}
|
||||
@@ -137,12 +141,11 @@ async function readOriginalParsedFiles(stagingId: string, manifest: StagingManif
|
||||
)
|
||||
}
|
||||
|
||||
async function buildPreparedPushFiles(stagingId: string, manifest: StagingManifest): Promise<PushFile[]> {
|
||||
async function buildPreparedPushFiles(stagingId: string, manifest: StagingManifest, prepared: StagedPreparedData): Promise<PushFile[]> {
|
||||
const preparedDir = getPreparedDir(stagingId)
|
||||
const preparedFiles = manifest.prepared?.assetSummaries || []
|
||||
|
||||
return Promise.all(
|
||||
preparedFiles.map(async (file) => {
|
||||
prepared.assetSummaries.map(async (file) => {
|
||||
const buffer = await readFile(join(preparedDir, file.filename))
|
||||
return {
|
||||
path: getModelAssetPath(manifest.folderName, file.filename),
|
||||
@@ -157,7 +160,11 @@ export async function ensurePreparedStagingAssets(stagingId: string): Promise<Pr
|
||||
|
||||
if (!manifest.prepared) {
|
||||
const parsedFiles = await readOriginalParsedFiles(stagingId, manifest)
|
||||
const prepared = await prepareGitAssets({ folderName: manifest.folderName, parsedFiles })
|
||||
const prepared = await prepareGitAssets({
|
||||
folderName: manifest.folderName,
|
||||
parsedFiles,
|
||||
gitModelMode: manifest.gitModelMode,
|
||||
})
|
||||
const preparedDir = getPreparedDir(stagingId)
|
||||
await mkdir(preparedDir, { recursive: true })
|
||||
|
||||
@@ -181,7 +188,7 @@ export async function ensurePreparedStagingAssets(stagingId: string): Promise<Pr
|
||||
|
||||
return {
|
||||
folderName: manifest.folderName,
|
||||
filesToPush: await buildPreparedPushFiles(stagingId, manifest),
|
||||
filesToPush: await buildPreparedPushFiles(stagingId, manifest, manifest.prepared),
|
||||
modelFilename: manifest.prepared.modelFilename,
|
||||
assetSummaries: manifest.prepared.assetSummaries,
|
||||
compressed: manifest.prepared.compressed,
|
||||
|
||||
@@ -14,7 +14,7 @@ interface GltfJson {
|
||||
}
|
||||
|
||||
/** Discriminated union: either valid (with model) or invalid (with errors). */
|
||||
export type ValidationResult =
|
||||
type ValidationResult =
|
||||
| { ok: true; model: File; textures: TextureFile[]; warnings: string[] }
|
||||
| { ok: false; errors: string[] }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user