#!/usr/bin/env node /** * ═══════════════════════════════════════════ * NEXUS TEXTURE AUDIT CLI — Standalone Audit Tool * ═══════════════════════════════════════════ * * Command-line tool to audit textures in the Nexus project. * Provides compression recommendations and VRAM estimates. */ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); // Configuration const CONFIG = { maxTextureSize: 2048, maxTotalTextures: 50, maxTotalVRAM: 256 * 1024 * 1024, // 256MB textureExtensions: ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.basis', '.ktx2'], imageMagickPath: 'convert', // Path to ImageMagick convert }; class TextureAuditor { constructor(projectRoot) { this.projectRoot = projectRoot; this.textureFiles = []; this.auditResults = []; this.totalVRAM = 0; } /** * Scan project for texture files */ scanForTextures() { console.log(`Scanning ${this.projectRoot} for textures...`); const scanDir = (dir) => { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { // Skip node_modules and .git if (entry.name !== 'node_modules' && entry.name !== '.git') { scanDir(fullPath); } } else if (entry.isFile()) { const ext = path.extname(entry.name).toLowerCase(); if (CONFIG.textureExtensions.includes(ext)) { this.textureFiles.push(fullPath); } } } }; scanDir(this.projectRoot); console.log(`Found ${this.textureFiles.length} texture files`); return this.textureFiles; } /** * Audit a single texture file */ auditTexture(filePath) { const result = { file: path.relative(this.projectRoot, filePath), issues: [], recommendations: [], score: 100, }; try { const stats = fs.statSync(filePath); const fileSize = stats.size; const ext = path.extname(filePath).toLowerCase(); // Get image dimensions if possible let width = 0; let height = 0; try { const identify = execSync(`${CONFIG.imageMagickPath} -format "%wx%h" "${filePath}"`, { encoding: 'utf8' }); const match = identify.match(/(\d+)x(\d+)/); if (match) { width = parseInt(match[1]); height = parseInt(match[2]); } } catch (e) { // ImageMagick not available, skip dimension check } // Calculate estimated VRAM (RGBA) const vram = width * height * 4; this.totalVRAM += vram; // Check file size if (fileSize > 10 * 1024 * 1024) { // >10MB result.issues.push(`Large file size: ${(fileSize / 1024 / 1024).toFixed(1)}MB`); result.recommendations.push('Consider compressing or using a different format'); result.score -= 20; } // Check dimensions if (width > CONFIG.maxTextureSize || height > CONFIG.maxTextureSize) { result.issues.push(`Texture too large: ${width}x${height} (max: ${CONFIG.maxTextureSize}x${CONFIG.maxTextureSize})`); result.recommendations.push(`Resize to ${CONFIG.maxTextureSize}x${CONFIG.maxTextureSize} or smaller`); result.score -= 30; } // Check if power of two if (width > 0 && height > 0) { if (!this.isPowerOfTwo(width) || !this.isPowerOfTwo(height)) { result.issues.push('Texture dimensions not power of two'); result.recommendations.push('Resize to nearest power of two (e.g., 512x512, 1024x1024)'); result.score -= 15; } } // Check format if (ext === '.png' || ext === '.jpg' || ext === '.jpeg') { result.recommendations.push('Consider using WebP for better compression'); result.score -= 10; } // Check VRAM usage if (vram > 16 * 1024 * 1024) { // >16MB result.issues.push(`High VRAM usage: ${(vram / 1024 / 1024).toFixed(1)}MB`); result.recommendations.push('Use compressed texture format (WebP, Basis, or KTX2)'); result.score -= 20; } } catch (error) { result.issues.push(`Error reading file: ${error.message}`); result.score = 0; } result.score = Math.max(0, result.score); this.auditResults.push(result); return result; } /** * Run full audit */ audit() { this.scanForTextures(); console.log('\n=== Texture Audit Results ===\n'); let totalScore = 0; let issuesFound = 0; for (const file of this.textureFiles) { const result = this.auditTexture(file); totalScore += result.score; issuesFound += result.issues.length; if (result.issues.length > 0) { console.log(`\n${result.file}:`); console.log(` Score: ${result.score}/100`); result.issues.forEach(issue => console.log(` ⚠️ ${issue}`)); result.recommendations.forEach(rec => console.log(` 💡 ${rec}`)); } } // Summary console.log('\n=== Audit Summary ==='); console.log(`Total textures: ${this.textureFiles.length}`); console.log(`Total VRAM: ${(this.totalVRAM / 1024 / 1024).toFixed(1)}MB`); console.log(`Average score: ${(totalScore / this.textureFiles.length).toFixed(1)}/100`); console.log(`Issues found: ${issuesFound}`); console.log(`Texture limit: ${this.textureFiles.length > CONFIG.maxTotalTextures ? 'EXCEEDED' : 'OK'}`); console.log(`VRAM limit: ${this.totalVRAM > CONFIG.maxTotalVRAM ? 'EXCEEDED' : 'OK'}`); // Generate compression plan this.generateCompressionPlan(); } /** * Generate compression plan */ generateCompressionPlan() { console.log('\n=== Compression Plan ==='); const webpCandidates = []; const resizeCandidates = []; for (const result of this.auditResults) { if (result.score < 80) { const ext = path.extname(result.file).toLowerCase(); if (ext === '.png' || ext === '.jpg' || ext === '.jpeg') { webpCandidates.push(result.file); } if (result.issues.some(i => i.includes('too large'))) { resizeCandidates.push(result.file); } } } console.log(`Textures to convert to WebP: ${webpCandidates.length}`); webpCandidates.forEach(f => console.log(` 📦 ${f}`)); console.log(`Textures to resize: ${resizeCandidates.length}`); resizeCandidates.forEach(f => console.log(` 📐 ${f}`)); if (webpCandidates.length > 0) { console.log('\nTo convert to WebP:'); console.log(' for file in *.png; do cwebp -q 80 "$file" -o "${file%.png}.webp"; done'); } } /** * Check if number is power of two */ isPowerOfTwo(n) { return n !== 0 && (n & (n - 1)) === 0; } /** * Save audit results to JSON */ saveResults(outputPath) { const report = { timestamp: new Date().toISOString(), projectRoot: this.projectRoot, totalTextures: this.textureFiles.length, totalVRAM: this.totalVRAM, averageScore: this.auditResults.reduce((sum, r) => sum + r.score, 0) / this.auditResults.length, results: this.auditResults, }; fs.writeFileSync(outputPath, JSON.stringify(report, null, 2)); console.log(`\nAudit results saved to: ${outputPath}`); } } // CLI interface if (require.main === module) { const projectRoot = process.argv[2] || process.cwd(); const auditor = new TextureAuditor(projectRoot); auditor.audit(); // Save results if output path provided if (process.argv[3]) { auditor.saveResults(process.argv[3]); } } module.exports = TextureAuditor;