Files
upload-gltf/lib/texture-compression.ts
T
2026-05-17 16:18:17 +02:00

203 lines
5.6 KiB
TypeScript

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'
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,
): Promise<TextureCompressionResult> {
const ext = extname(filename).toLowerCase()
try {
if (ext === '.jpg' || ext === '.jpeg') {
return {
buffer: await sharp(buffer).jpeg({ quality: 82, mozjpeg: true }).toBuffer(),
compressed: true,
}
}
if (ext === '.png') {
return {
buffer: await sharp(buffer).png({ compressionLevel: 9, adaptiveFiltering: true }).toBuffer(),
compressed: true,
}
}
if (ext === '.webp') {
return {
buffer: await sharp(buffer).webp({ quality: 82 }).toBuffer(),
compressed: true,
}
}
} catch (err) {
const message = getErrorMessage(err, String(err))
return {
buffer,
compressed: false,
error: `Compression texture echouee pour ${filename}: ${message}`,
}
}
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}`,
}
}
}
}