feat: add ktx2 texture fallback flow

This commit is contained in:
Tom Boullay
2026-05-17 16:18:17 +02:00
parent 81c513ee1f
commit 3cfb3a21a9
8 changed files with 390 additions and 30 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ export const ASSET_EXTENSIONS = new Set(['.bin'])
export const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS])
/** Extensions tracked by Git LFS (must match .gitattributes) */
export const LFS_EXTENSIONS = new Set(['.glb', '.gltf', '.bin', '.png', '.jpg', '.jpeg', '.webp'])
export const LFS_EXTENSIONS = new Set(['.glb', '.gltf', '.bin', '.png', '.jpg', '.jpeg', '.webp', '.ktx2'])
export const TMP_DIR = '/tmp/assets'
+182 -14
View File
@@ -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,
})
}
+156 -2
View File
@@ -1,13 +1,133 @@
import { extname } from 'path'
import { randomUUID } from 'crypto'
import { spawn } from 'child_process'
import { mkdir, readFile, rm } from 'fs/promises'
import { extname, join } from 'path'
import { tmpdir } from 'os'
import sharp from 'sharp'
import { getErrorMessage } from './guards'
interface TextureCompressionResult {
export interface TextureCompressionResult {
buffer: Buffer
compressed: boolean
error?: string
}
interface TextureDeliveryAsset {
filename: string
buffer: Buffer
compressed: boolean
}
export interface TextureDeliveryResult {
asset: TextureDeliveryAsset
format: 'ktx2' | 'webp' | 'original'
warning?: string
}
const DEFAULT_TEXTURE_SIZE = 1024
const WEBP_QUALITY = 82
const KTX2_EXTENSION = '.ktx2'
const WEBP_EXTENSION = '.webp'
function getTextureMaxSize() {
const value = Number(process.env.TEXTURE_MAX_SIZE || DEFAULT_TEXTURE_SIZE)
return Number.isFinite(value) && value > 0 ? value : DEFAULT_TEXTURE_SIZE
}
function replaceExtension(filename: string, extension: string) {
return filename.replace(/\.[^.]+$/, extension)
}
function getTextureFamily(filename: string) {
return filename
.replace(/\.[^.]+$/, '')
.split(/[_-]/)[0]
.toLowerCase()
}
function getKtx2Flags(filename: string) {
const family = getTextureFamily(filename)
const flags = ['--t2', '--genmipmap']
if (family === 'color' || family === 'diffuse') {
flags.push('--assign_oetf', 'srgb')
}
if (family === 'normal') {
flags.push('--encode', 'uastc', '--zcmp', '18')
return flags
}
flags.push('--encode', 'etc1s', '--clevel', '2', '--qlevel', '128')
return flags
}
function runToktx(args: string[]) {
const toktxPath = process.env.TOKTX_PATH || 'toktx'
return new Promise<void>((resolve, reject) => {
const output: string[] = []
const proc = spawn(toktxPath, args)
proc.stdout.on('data', (data: Buffer) => output.push(data.toString()))
proc.stderr.on('data', (data: Buffer) => output.push(data.toString()))
proc.on('error', (err) => reject(err))
proc.on('close', (code) => {
if (code === 0) {
resolve()
return
}
reject(new Error(output.join('').trim() || `toktx exited with code ${code}`))
})
})
}
async function writeResizedPng(input: Buffer, outputPath: string) {
await sharp(input)
.resize(getTextureMaxSize(), getTextureMaxSize(), { fit: 'inside', withoutEnlargement: true })
.png()
.toFile(outputPath)
}
async function compressTextureToKtx2(filename: string, buffer: Buffer): Promise<TextureDeliveryAsset> {
const tmpFolder = join(/* turbopackIgnore: true */ tmpdir(), `upload-gltf-ktx2-${randomUUID()}`)
const inputPath = join(/* turbopackIgnore: true */ tmpFolder, 'texture.png')
const outputFilename = replaceExtension(filename, KTX2_EXTENSION)
const outputPath = join(/* turbopackIgnore: true */ tmpFolder, outputFilename)
await mkdir(/* turbopackIgnore: true */ tmpFolder, { recursive: true })
try {
await writeResizedPng(buffer, inputPath)
await runToktx([...getKtx2Flags(filename), outputPath, inputPath])
return {
filename: outputFilename,
buffer: await readFile(/* turbopackIgnore: true */ outputPath),
compressed: true,
}
} finally {
await rm(/* turbopackIgnore: true */ tmpFolder, { recursive: true, force: true }).catch((err) => {
console.warn('[WARN] KTX2 temp cleanup failed', {
filename,
error: getErrorMessage(err),
})
})
}
}
async function compressTextureToWebp(filename: string, buffer: Buffer): Promise<TextureDeliveryAsset> {
return {
filename: replaceExtension(filename, WEBP_EXTENSION),
buffer: await sharp(buffer)
.resize(getTextureMaxSize(), getTextureMaxSize(), { fit: 'inside', withoutEnlargement: true })
.webp({ quality: WEBP_QUALITY })
.toBuffer(),
compressed: true,
}
}
export async function compressTextureBuffer(
filename: string,
buffer: Buffer,
@@ -46,3 +166,37 @@ export async function compressTextureBuffer(
return { buffer, compressed: false }
}
export async function prepareTextureDelivery(
filename: string,
buffer: Buffer,
): Promise<TextureDeliveryResult> {
try {
return {
asset: await compressTextureToKtx2(filename, buffer),
format: 'ktx2',
}
} catch (err) {
const ktx2Error = getErrorMessage(err, String(err))
try {
return {
asset: await compressTextureToWebp(filename, buffer),
format: 'webp',
warning: `Compression KTX2 impossible pour ${filename}. Fallback WebP utilise. Detail : ${ktx2Error}`,
}
} catch (webpErr) {
const webpError = getErrorMessage(webpErr, String(webpErr))
return {
asset: {
filename,
buffer,
compressed: false,
},
format: 'original',
warning: `Compression KTX2 impossible pour ${filename}. Compression WebP impossible. Texture originale conservee. Details : ${ktx2Error} / ${webpError}`,
}
}
}
}