/** * Model-Proxy-Service: Lädt GLB-Dateien, komprimiert sie mit gltf-transform (Draco + Textur-Optimierung) * und legt sie im Datei-Cache ab. Weitere Requests werden aus dem Cache bedient. */ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { spawn } from 'child_process'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const BACKEND_DIR = path.join(__dirname, '..'); const PROJECT_ROOT = path.join(BACKEND_DIR, '..'); const MODELS_REL = path.join('models', '3d', 'falukant', 'characters'); const DIST_MODELS = path.join(PROJECT_ROOT, 'frontend', 'dist', MODELS_REL); const PUBLIC_MODELS = path.join(PROJECT_ROOT, 'frontend', 'public', MODELS_REL); const CACHE_DIR = path.join(BACKEND_DIR, 'data', 'model-cache'); const CLI_PATH = path.join(BACKEND_DIR, 'node_modules', '.bin', 'gltf-transform'); /** Einmal ermitteltes Quellverzeichnis (frontend/dist oder frontend/public). */ let _sourceDir = null; /** Production: frontend/dist; Local: frontend/public. Einmal pro Prozess festgelegt, damit * isCacheValid() stets gegen dieselbe Quelle prüft (kein Wechsel zwischen dist/public). */ function getSourceDir() { if (_sourceDir !== null) return _sourceDir; _sourceDir = fs.existsSync(DIST_MODELS) ? DIST_MODELS : PUBLIC_MODELS; return _sourceDir; } /** Erlaubte Dateinamen (nur [a-z0-9_.-]+.glb) */ const FILENAME_RE = /^[a-z0-9_.-]+\.glb$/i; /** Laufende Optimierungen pro Dateiname → Promise (Cache-Pfad) */ const pending = new Map(); /** * Stellt sicher, dass der Cache-Ordner existiert. */ function ensureCacheDir() { if (!fs.existsSync(CACHE_DIR)) { fs.mkdirSync(CACHE_DIR, { recursive: true }); } } /** * Prüft, ob die Cache-Datei gültig ist (existiert und ist nicht älter als die Quelle). * @param {string} sourcePath * @param {string} cachePath * @returns {boolean} */ function isCacheValid(sourcePath, cachePath) { if (!fs.existsSync(cachePath)) return false; if (!fs.existsSync(sourcePath)) return false; const sourceStat = fs.statSync(sourcePath); const cacheStat = fs.statSync(cachePath); return cacheStat.mtimeMs >= sourceStat.mtimeMs; } /** * Führt gltf-transform optimize aus (Draco + texture-size 1024). * @param {string} inputPath * @param {string} outputPath * @returns {Promise} */ function runOptimize(inputPath, outputPath) { return new Promise((resolve, reject) => { const child = spawn( 'node', [CLI_PATH, 'optimize', inputPath, outputPath, '--compress', 'draco', '--texture-size', '1024'], { cwd: BACKEND_DIR, stdio: ['ignore', 'pipe', 'pipe'] } ); let stderr = ''; child.stderr?.on('data', (d) => { stderr += d.toString(); }); child.on('close', (code) => { if (code === 0) resolve(); else reject(new Error(`gltf-transform exit ${code}: ${stderr}`)); }); child.on('error', reject); }); } /** * Liefert den Pfad zur optimierten (gecachten) GLB-Datei. * Erstellt die optimierte Datei per gltf-transform, falls nicht (gültig) gecacht. * * @param {string} filename - z.B. "male_child.glb" * @returns {Promise} Absoluter Pfad zur optimierten Datei (Cache) * @throws {Error} Bei ungültigem Dateinamen oder fehlender Quelldatei */ export async function getOptimizedModelPath(filename) { if (!FILENAME_RE.test(filename)) { throw new Error(`Invalid model filename: ${filename}`); } const sourceDir = getSourceDir(); const sourcePath = path.join(sourceDir, filename); const cacheFilename = filename.replace(/\.glb$/, '_opt.glb'); const cachePath = path.join(CACHE_DIR, cacheFilename); if (!fs.existsSync(sourcePath)) { throw new Error(`Source model not found: ${filename} (looked in ${sourceDir})`); } ensureCacheDir(); if (isCacheValid(sourcePath, cachePath)) { return cachePath; } let promise = pending.get(filename); if (!promise) { promise = (async () => { try { await runOptimize(sourcePath, cachePath); return cachePath; } finally { pending.delete(filename); } })(); pending.set(filename, promise); } return promise; }