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, 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' interface PrepareGitAssetsParams { folderName: string parsedFiles: ParsedFile[] gitModelMode: GitModelMode } 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>() for (const file of parsedFiles) { const ext = extname(file.filename).toLowerCase() if (!TEXTURE_EXTENSIONS.has(ext)) continue const normalizedFilename = normalizeTextureFilename(file.filename) if (!normalizedFilename) continue const normalizedKey = normalizedFilename.toLowerCase() const group = normalizedGroups.get(normalizedKey) || [] group.push({ original: file.filename, normalized: normalizedFilename }) normalizedGroups.set(normalizedKey, group) } for (const group of normalizedGroups.values()) { if (group.length > 1) continue const [{ original, normalized }] = group filenameMap.set(original.toLowerCase(), normalized) } return filenameMap } function getReferencedFilename(uri: string) { const cleanUri = decodeURIComponent(uri.split(/[?#]/)[0] || '') return cleanUri.split(/[\\/]/).pop()?.toLowerCase() } function rewriteGltfUris(value: JsonValue, filenameMap: Map): JsonValue { if (Array.isArray(value)) { return value.map((entry) => rewriteGltfUris(entry, filenameMap)) } if (!value || typeof value !== 'object') return value const rewritten: Record = {} for (const [key, entry] of Object.entries(value)) { if (key === 'uri' && typeof entry === 'string') { const filename = getReferencedFilename(entry) rewritten[key] = filename ? filenameMap.get(filename) || entry : entry continue } rewritten[key] = rewriteGltfUris(entry, filenameMap) } return rewritten } function prepareModelBuffer(buffer: Buffer, filenameMap: Map) { if (filenameMap.size === 0) return buffer const parsed = parseJsonValue(buffer.toString('utf-8')) return Buffer.from(JSON.stringify(rewriteGltfUris(parsed, filenameMap), null, 2), 'utf-8') } async function prepareSeparateFiles( folderName: string, parsedFiles: ParsedFile[], textureFilenameMap: Map, ) { const filesToPush: PushFile[] = [] const assetSummaries: PreparedAssetSummary[] = [] let modelFilename = '' let compressed = false let compressionError: string | undefined for (const pf of parsedFiles) { let content = pf.buffer let filename = pf.filename if (pf.isModel) { content = prepareModelBuffer(pf.buffer, textureFilenameMap) modelFilename = pf.filename assetSummaries.push({ filename, kind: 'model', compressed: false, }) } else { filename = textureFilenameMap.get(pf.filename.toLowerCase()) || pf.filename const categoryFilename = textureFilenameMap.get(pf.filename.toLowerCase()) || normalizeTextureFilename(pf.filename) || pf.filename const category = classifyAssetCategory(categoryFilename) const textureResult = await compressTextureBuffer(filename, pf.buffer) content = textureResult.buffer compressed ||= textureResult.compressed if (textureResult.error && !compressionError) { compressionError = textureResult.error } assetSummaries.push({ filename, kind: category === 'assets' ? 'asset' : 'texture', category, compressed: textureResult.compressed, }) } filesToPush.push({ path: getModelAssetPath(folderName, filename), contentBase64: content.toString('base64'), }) } return { filesToPush, modelFilename, assetSummaries, compressed, compressionError, deliveryMode: 'keep-gltf' as const, } } async function prepareDracoGlb( folderName: string, parsedFiles: ParsedFile[], textureFilenameMap: Map, ): Promise { 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) } 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' 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(() => {}) } } export async function prepareGitAssets({ folderName, parsedFiles, gitModelMode, }: PrepareGitAssetsParams): Promise { const textureFilenameMap = getTextureFilenameMap(parsedFiles) if (gitModelMode === 'keep-gltf') { return prepareSeparateFiles(folderName, parsedFiles, textureFilenameMap) } return prepareDracoGlb(folderName, parsedFiles, textureFilenameMap) }