feat: add ktx2 texture fallback flow
This commit is contained in:
+156
-2
@@ -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}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user