248 lines
7.6 KiB
TypeScript
248 lines
7.6 KiB
TypeScript
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<string, string>()
|
|
const normalizedGroups = new Map<string, Array<{ original: string; normalized: string }>>()
|
|
|
|
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<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, JsonValue> = {}
|
|
|
|
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<string, string>) {
|
|
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<string, string>,
|
|
) {
|
|
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<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)
|
|
}
|
|
|
|
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<PreparedGitAssetsResult> {
|
|
const textureFilenameMap = getTextureFilenameMap(parsedFiles)
|
|
|
|
if (gitModelMode === 'keep-gltf') {
|
|
return prepareSeparateFiles(folderName, parsedFiles, textureFilenameMap)
|
|
}
|
|
|
|
return prepareDracoGlb(folderName, parsedFiles, textureFilenameMap)
|
|
}
|