- 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>
191 lines
6.9 KiB
JavaScript
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();
|