#!/usr/bin/env node // SHRINKGOD CLI — same pipeline as the web UI, headless. // shrinkgod model.glb -> model.opt.glb (balanced) // shrinkgod --preset crunch *.glb --outdir out/ // shrinkgod model.glb --ratio 0.15 --max-tex 512 --webp -o small.glb import { parseArgs } from 'node:util'; import { readFile, writeFile, mkdir } from 'node:fs/promises'; import path from 'node:path'; import { NodeIO } from '@gltf-transform/core'; import { ALL_EXTENSIONS } from '@gltf-transform/extensions'; import { listTextureSlots } from '@gltf-transform/functions'; import { MeshoptDecoder, MeshoptEncoder } from 'meshoptimizer'; import draco3d from 'draco3dgltf'; import { runPipeline, reportDoc, isColorSlot, PRESETS } from '../src/pipeline.js'; const HELP = `SHRINKGOD — local GLB optimizer (CLI) Usage: shrinkgod [options] [more.glb ...] Options: --preset base settings (default: balanced) --ratio <0..1> fraction of geometry to keep (overrides preset) --error <0..1> simplify error tolerance, fraction of mesh radius --max-tex longest texture side (0 = leave textures alone) --webp / --no-webp convert color maps to WebP --quality <0..100> lossy texture quality (default 85) --no-join don't merge static meshes --no-quantize skip vertex quantization --meshopt EXT_meshopt_compression (web loaders only; Blender can't read it) -o, --out output path (single input only) --outdir output directory for batch runs --suffix output suffix (default ".opt") -h, --help Rig protection is automatic: skinned meshes keep >=50% geometry, morph-target meshes are untouched, joining is disabled for animated models.`; function fmtBytes(n) { if (n >= 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + 'MB'; if (n >= 1024) return (n / 1024).toFixed(0) + 'KB'; return n + 'B'; } async function nodeTextureStep(doc, opts, notes) { const textures = doc.getRoot().listTextures(); if (!textures.length) return; let sharp; try { sharp = (await import('sharp')).default; } catch { notes.push('sharp not installed — textures left untouched (npm install sharp)'); return; } for (const tex of textures) { const image = tex.getImage(); if (!image) continue; const mime = tex.getMimeType(); if (!/^image\/(png|jpeg|webp)$/.test(mime)) continue; let img, meta; try { img = sharp(Buffer.from(image.buffer, image.byteOffset, image.byteLength)); meta = await img.metadata(); } catch { notes.push(`could not decode texture "${tex.getName() || mime}" — left as-is`); continue; } const longest = Math.max(meta.width, meta.height); const scale = opts.maxTex > 0 ? Math.min(1, opts.maxTex / longest) : 1; const needsResize = scale < 1; const slots = listTextureSlots(tex); const color = slots.length === 0 || slots.some(isColorSlot); let target; // sharp format name if (!color) target = 'png'; else if (opts.webp) target = 'webp'; else target = mime === 'image/webp' ? 'webp' : mime.slice(6); // png|jpeg if (!needsResize && target === 'png' && mime === 'image/png') continue; if (needsResize) { img = img.resize( Math.max(1, Math.round(meta.width * scale)), Math.max(1, Math.round(meta.height * scale)), { fit: 'fill' } ); } const q = Math.round(opts.texQuality * 100); if (target === 'webp') img = img.webp({ quality: q }); else if (target === 'jpeg') img = img.jpeg({ quality: q }); else img = img.png(); const out = new Uint8Array(await img.toBuffer()); if (!needsResize && out.byteLength >= image.byteLength) continue; tex.setImage(out).setMimeType('image/' + target); } } async function main() { const { values: v, positionals } = parseArgs({ allowPositionals: true, options: { preset: { type: 'string', default: 'balanced' }, ratio: { type: 'string' }, error: { type: 'string' }, 'max-tex': { type: 'string' }, webp: { type: 'boolean' }, 'no-webp': { type: 'boolean' }, quality: { type: 'string', default: '85' }, 'no-join': { type: 'boolean' }, 'no-quantize': { type: 'boolean' }, meshopt: { type: 'boolean' }, out: { type: 'string', short: 'o' }, outdir: { type: 'string' }, suffix: { type: 'string', default: '.opt' }, help: { type: 'boolean', short: 'h' }, }, }); if (v.help || positionals.length === 0) { console.log(HELP); process.exit(v.help ? 0 : 1); } const preset = PRESETS[v.preset]; if (!preset) { console.error(`unknown preset "${v.preset}" (light|balanced|crunch)`); process.exit(1); } if (v.out && positionals.length > 1) { console.error('--out only works with a single input; use --outdir for batches'); process.exit(1); } const opts = { ratio: v.ratio != null ? parseFloat(v.ratio) : preset.ratio, error: v.error != null ? parseFloat(v.error) : preset.error, maxTex: v['max-tex'] != null ? parseInt(v['max-tex'], 10) : preset.maxTex, webp: v['no-webp'] ? false : v.webp != null ? v.webp : preset.webp, texQuality: parseInt(v.quality, 10) / 100, join: !v['no-join'], quantize: !v['no-quantize'], meshopt: !!v.meshopt, }; await Promise.all([MeshoptDecoder.ready, MeshoptEncoder.ready]); const io = new NodeIO() .registerExtensions(ALL_EXTENSIONS) .registerDependencies({ 'meshopt.decoder': MeshoptDecoder, 'meshopt.encoder': MeshoptEncoder, 'draco3d.decoder': await draco3d.createDecoderModule(), }); if (v.outdir) await mkdir(v.outdir, { recursive: true }); let failed = 0; for (const input of positionals) { const t0 = performance.now(); try { const inBytes = await readFile(input); const doc = await io.readBinary(new Uint8Array(inBytes)); const before = reportDoc(doc, inBytes.byteLength); const { notes } = await runPipeline(doc, opts, { textureStep: nodeTextureStep }); const outBytes = await io.writeBinary(doc); const after = reportDoc(doc, outBytes.byteLength); const base = path.basename(input).replace(/\.glb$/i, ''); const outPath = v.out ?? path.join(v.outdir ?? path.dirname(input), `${base}${v.suffix}.glb`); await writeFile(outPath, outBytes); const secs = ((performance.now() - t0) / 1000).toFixed(1); const saved = (1 - after.bytes / before.bytes) * 100; console.log( `${input} -> ${outPath}\n` + ` ${fmtBytes(before.bytes)} -> ${fmtBytes(after.bytes)} (-${saved.toFixed(0)}%) ` + `tris ${before.tris.toLocaleString()} -> ${after.tris.toLocaleString()} ` + `tex ${fmtBytes(before.texBytes)} -> ${fmtBytes(after.texBytes)} [${secs}s]` ); for (const n of notes) console.log(` · ${n}`); } catch (e) { failed++; console.error(`${input}: FAILED — ${e.message || e}`); } } process.exit(failed ? 1 : 0); } main();