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, prepareTextureDelivery } 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 } type JsonObject = { [key: string]: JsonValue } interface PreparedTexturePlan { filename: string buffer: Buffer category: PreparedAssetSummary['category'] compressed: boolean format: 'ktx2' | 'webp' | 'original' } const KHR_TEXTURE_BASISU = 'KHR_texture_basisu' 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 isJsonObject(value: JsonValue): value is JsonObject { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) } function getJsonObjectArray(value: JsonValue | undefined) { if (!Array.isArray(value)) return null return value.every(isJsonObject) ? value : null } function getStringArray(value: JsonValue | undefined) { if (!Array.isArray(value)) return [] return value.filter((entry): entry is string => typeof entry === 'string') } function addExtensionUsed(gltf: JsonObject, extensionName: string) { const extensionsUsed = new Set(getStringArray(gltf.extensionsUsed)) extensionsUsed.add(extensionName) gltf.extensionsUsed = [...extensionsUsed] } function getOrCreateExtensions(value: JsonObject): JsonObject { if (!isJsonObject(value.extensions)) { value.extensions = {} } return value.extensions } 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 addKtx2TextureExtensions(value: JsonValue, ktx2Filenames: Set) { if (ktx2Filenames.size === 0 || !isJsonObject(value)) return value const images = getJsonObjectArray(value.images) const textures = getJsonObjectArray(value.textures) if (!images || !textures) return value let hasKtx2Texture = false for (let imageIndex = 0; imageIndex < images.length; imageIndex++) { const image = images[imageIndex] const uri = image.uri if (typeof uri !== 'string') continue const filename = getReferencedFilename(uri) if (!filename || !ktx2Filenames.has(filename)) continue image.mimeType = 'image/ktx2' for (const texture of textures) { if (texture.source !== imageIndex) continue const extensions = getOrCreateExtensions(texture) extensions[KHR_TEXTURE_BASISU] = { source: imageIndex } delete texture.source hasKtx2Texture = true } } if (hasKtx2Texture) { addExtensionUsed(value, KHR_TEXTURE_BASISU) const extensionsRequired = new Set(getStringArray(value.extensionsRequired)) extensionsRequired.add(KHR_TEXTURE_BASISU) value.extensionsRequired = [...extensionsRequired] } return value } function prepareModelBuffer( buffer: Buffer, filenameMap: Map, ktx2Filenames = new Set(), ) { if (filenameMap.size === 0) return buffer const parsed = parseJsonValue(buffer.toString('utf-8')) const rewritten = rewriteGltfUris(parsed, filenameMap) const withKtx2 = addKtx2TextureExtensions(rewritten, ktx2Filenames) return Buffer.from(JSON.stringify(withKtx2, null, 2), 'utf-8') } function formatCompressionWarnings(warnings: string[]) { if (warnings.length === 0) return undefined if (warnings.length === 1) return warnings[0] return `${warnings[0]} (${warnings.length - 1} autre(s) texture(s) en fallback WebP.)` } function reserveDeliveryFilename(filename: string, usedStems: Set) { const extension = extname(filename) const stem = filename.slice(0, -extension.length) let candidateStem = stem let suffix = 2 while (usedStems.has(candidateStem.toLowerCase())) { candidateStem = `${stem}_${suffix}` suffix += 1 } usedStems.add(candidateStem.toLowerCase()) return `${candidateStem}${extension}` } async function prepareTexturePlans( parsedFiles: ParsedFile[], textureFilenameMap: Map, ) { const plans = new Map() const warnings: string[] = [] const usedDeliveryStems = new Set() for (const pf of parsedFiles) { const ext = extname(pf.filename).toLowerCase() if (!TEXTURE_EXTENSIONS.has(ext)) continue const normalizedFilename = textureFilenameMap.get(pf.filename.toLowerCase()) || pf.filename const categoryFilename = normalizeTextureFilename(normalizedFilename) || normalizedFilename const category = classifyAssetCategory(categoryFilename) const deliveryFilename = reserveDeliveryFilename(normalizedFilename, usedDeliveryStems) const delivery = await prepareTextureDelivery(deliveryFilename, pf.buffer) if (delivery.warning) { warnings.push(delivery.warning) } plans.set(pf.filename.toLowerCase(), { filename: delivery.asset.filename, buffer: delivery.asset.buffer, category, compressed: delivery.asset.compressed, format: delivery.format, }) } return { plans, warning: formatCompressionWarnings(warnings), } } async function prepareSeparateFiles( folderName: string, parsedFiles: ParsedFile[], textureFilenameMap: Map, ) { const filesToPush: PushFile[] = [] const assetSummaries: PreparedAssetSummary[] = [] const { plans: texturePlans, warning: textureCompressionWarning } = await prepareTexturePlans( parsedFiles, textureFilenameMap, ) const deliveryFilenameMap = new Map(textureFilenameMap) const ktx2Filenames = new Set() let modelFilename = '' let compressed = false let compressionError: string | undefined = textureCompressionWarning for (const [originalFilename, texturePlan] of texturePlans) { deliveryFilenameMap.set(originalFilename, texturePlan.filename) if (texturePlan.format === 'ktx2') { ktx2Filenames.add(texturePlan.filename.toLowerCase()) } } for (const pf of parsedFiles) { let content = pf.buffer let filename = pf.filename if (pf.isModel) { content = prepareModelBuffer(pf.buffer, deliveryFilenameMap, ktx2Filenames) modelFilename = pf.filename assetSummaries.push({ filename, kind: 'model', compressed: false, }) } else if (texturePlans.has(pf.filename.toLowerCase())) { const texturePlan = texturePlans.get(pf.filename.toLowerCase()) if (!texturePlan) continue compressed ||= texturePlan.compressed assetSummaries.push({ filename: texturePlan.filename, kind: 'texture', category: texturePlan.category, compressed: texturePlan.compressed, }) filesToPush.push({ path: getModelAssetPath(folderName, texturePlan.filename), contentBase64: texturePlan.buffer.toString('base64'), }) continue } 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) assetSummaries.push({ filename, kind: category === 'assets' ? 'asset' : 'texture', category, compressed: false, }) } 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((err) => { console.warn('[WARN] Blender temp cleanup failed', { folderName, error: getErrorMessage(err), }) }) } } 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) }