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((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 { 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 { 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 { 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 { 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}`, } } } }