Files
upload-gltf/lib/prepare-git-assets.ts
2026-05-17 16:18:17 +02:00

416 lines
13 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, 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<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 addKtx2TextureExtensions(value: JsonValue, ktx2Filenames: Set<string>) {
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<string, string>,
ktx2Filenames = new Set<string>(),
) {
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<string>) {
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<string, string>,
) {
const plans = new Map<string, PreparedTexturePlan>()
const warnings: string[] = []
const usedDeliveryStems = new Set<string>()
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<string, string>,
) {
const filesToPush: PushFile[] = []
const assetSummaries: PreparedAssetSummary[] = []
const { plans: texturePlans, warning: textureCompressionWarning } = await prepareTexturePlans(
parsedFiles,
textureFilenameMap,
)
const deliveryFilenameMap = new Map(textureFilenameMap)
const ktx2Filenames = new Set<string>()
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<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)
}