203 lines
5.6 KiB
TypeScript
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}`,
|
|
}
|
|
}
|
|
}
|
|
}
|