shrinkgod/bin/cli.js
type-two c8c37dc0c5 Draco input support + headless CLI
- Shared pipeline extracted to src/pipeline.js (browser + node run identical logic)
- Draco: vendored decoder WASM in public/draco/, lazy-registered by peeking the
  GLB JSON chunk before read (gltf-transform fails cryptically on a mid-read
  missing dependency); DRACOLoader wired into the viewer; extension stripped on
  write so output is standard GLB
- bin/cli.js: NodeIO + draco3dgltf + sharp textures, presets/flag overrides,
  batch with --outdir, per-file report

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 23:37:02 +10:00

191 lines
6.9 KiB
JavaScript

#!/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] <input.glb> [more.glb ...]
Options:
--preset <light|balanced|crunch> 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 <px> 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 <file> output path (single input only)
--outdir <dir> output directory for batch runs
--suffix <s> 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();