Files
yourpart3/backend/services/modelsProxyService.js
Torsten Schulz (local) 82734e8383 Refactor source directory handling in modelsProxyService.js
- Introduced a cached source directory variable to optimize the retrieval of model paths.
- Updated comments for clarity on the source directory logic and its impact on cache validation.
2026-01-22 15:50:46 +01:00

126 lines
4.1 KiB
JavaScript

/**
* 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<string> (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<void>}
*/
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<string>} 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;
}