feat: add ktx2 texture fallback flow
This commit is contained in:
+182
-14
@@ -3,7 +3,7 @@ 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 { 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'
|
||||
@@ -18,6 +18,17 @@ interface PrepareGitAssetsParams {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -43,6 +54,34 @@ function parseJsonValue(content: string) {
|
||||
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 }>>()
|
||||
@@ -97,11 +136,115 @@ function rewriteGltfUris(value: JsonValue, filenameMap: Map<string, string>): Js
|
||||
return rewritten
|
||||
}
|
||||
|
||||
function prepareModelBuffer(buffer: Buffer, filenameMap: Map<string, string>) {
|
||||
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'))
|
||||
return Buffer.from(JSON.stringify(rewriteGltfUris(parsed, filenameMap), null, 2), '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(
|
||||
@@ -111,16 +254,30 @@ async function prepareSeparateFiles(
|
||||
) {
|
||||
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
|
||||
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, textureFilenameMap)
|
||||
content = prepareModelBuffer(pf.buffer, deliveryFilenameMap, ktx2Filenames)
|
||||
modelFilename = pf.filename
|
||||
|
||||
assetSummaries.push({
|
||||
@@ -128,24 +285,35 @@ async function prepareSeparateFiles(
|
||||
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)
|
||||
|
||||
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,
|
||||
compressed: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user