commit ab8f5ee38359674ad032e54a44d6d6e0bcd4b7a0 Author: type-two Date: Sun Aug 30 22:19:14 2026 +1000 SHRINKGOD v1: local GLB optimizer β€” synced compare viewer, rig-protected simplify, texture crunch Co-Authored-By: Claude Fable 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8777b7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +public/test-*.glb diff --git a/README.md b/README.md new file mode 100644 index 0000000..73648db --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# πŸ—œοΈ SHRINKGOD β€” local GLB optimizer + +Drop in a heavy GLB, pick a target, compare original vs optimized side-by-side (synced camera, animation playback, wireframe), export. **Everything runs client-side in the browser** β€” gltf-transform + meshoptimizer WASM. No uploads, files never leave the machine. + +## Run + +```bash +npm install +npm run dev # http://localhost:5173 +npm run build # static build in dist/ β€” host anywhere, still fully local +``` + +Dev test hook: `?test=.glb&run=1` auto-loads and optimizes. + +## Pipeline + +dedup β†’ flatten+join (static models only) β†’ weld β†’ per-primitive meshopt simplify β†’ animation resample β†’ prune β†’ texture resize/re-encode β†’ quantize β†’ (optional) meshopt compression. + +### Rig protection (automatic) +- Skinned meshes never go below **50% kept geometry** and error is capped at 0.5% β€” vertex collapse near joints wrecks skin weights in motion. +- Morph-target (blendshape) primitives are left untouched. +- Mesh joining is disabled for rigged/animated models (would break node animation). +- Best results: **optimize before rigging** β€” static geometry can be crunched far harder. + +### Textures +- Longest side clamped to the chosen max (canvas re-encode). +- Color/emissive maps optionally converted to WebP (quality slider); normal/roughness/occlusion maps always stay lossless PNG. +- Re-encode is skipped if it wouldn't shrink the file. + +### Presets +| | geometry kept | error | max texture | webp | +|---|---|---|---|---| +| Light | 50% | 0.5% | 2048 | no | +| Balanced | 25% | 1% | 1024 | yes | +| Crunch | 10% | 5% | 512 | yes | + +## Known limits +- Draco-compressed input not supported yet (friendly error; re-export without Draco). +- Meshopt compression output (`EXT_meshopt_compression`) reads fine in three.js but **not in Blender** β€” leave it off for assets going back into a DCC. +- KTX2/basis textures pass through untouched. + +## Bench (M5 MacBook Pro, Balanced preset) +- 15.8 MB photogrammetry room scan (151k tris, 13 MB textures) β†’ **1.5 MB** (βˆ’90%) in 5 s. +- 3.5 MB rigged character (18.6k tris, 20 clips) β†’ **2.7 MB** (βˆ’24%), clips + skinning intact (rig clamp limits geometry reduction by design). diff --git a/index.html b/index.html new file mode 100644 index 0000000..235a165 --- /dev/null +++ b/index.html @@ -0,0 +1,106 @@ + + + + + + SHRINKGOD β€” local GLB optimizer + + + + +
+
πŸ—œοΈ SHRINKGODGLB optimizer
+
πŸ”’ 100% local β€” files never leave this machine
+
+ +
+ + +
+
+
+
β¬‡οΈŽ
+
Drop a .glb here
or
+ + +
+
+ + +
+
+ + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8ff68de --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1601 @@ +{ + "name": "shrinkgod", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shrinkgod", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@gltf-transform/core": "^4.4.2", + "@gltf-transform/extensions": "^4.4.2", + "@gltf-transform/functions": "^4.4.2", + "meshoptimizer": "^1.2.0", + "three": "^0.185.1" + }, + "devDependencies": { + "vite": "^8.2.2" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@gltf-transform/core": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@gltf-transform/core/-/core-4.4.2.tgz", + "integrity": "sha512-qsWKwNSwK+2s834Mt4xbYcyHqCrgNFP7hIv5s487JxebngRfDgelpghNF+kSswGb2/NuapasfK3UViFoSJJoMg==", + "license": "MIT", + "dependencies": { + "property-graph": "^4.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/donmccurdy" + } + }, + "node_modules/@gltf-transform/extensions": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@gltf-transform/extensions/-/extensions-4.4.2.tgz", + "integrity": "sha512-HJH1FM+edC5eNvl6xO0SOXJ/j/3oDoIpSu150OTdJaLBoM3TgCCGIfh4wyhgWAqZrkvgHKVGiZKxcKV5LkgPCQ==", + "license": "MIT", + "dependencies": { + "@gltf-transform/core": "^4.4.2", + "ktx-parse": "^1.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/donmccurdy" + } + }, + "node_modules/@gltf-transform/functions": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@gltf-transform/functions/-/functions-4.4.2.tgz", + "integrity": "sha512-dclXgv9TshMaWBqPDUYd4xTwBQ2PpuR8p0Y9pokrRzGQDUPXRP6lTDzbqT0UmEmxFSvRyPJjvOWUmzeiRafpvw==", + "license": "MIT", + "dependencies": { + "@gltf-transform/core": "^4.4.2", + "@gltf-transform/extensions": "^4.4.2", + "ktx-parse": "^1.1.0", + "ndarray": "^1.0.19", + "ndarray-lanczos": "^0.3.0", + "ndarray-pixels": "^5.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/donmccurdy" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", + "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ndarray": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/ndarray/-/ndarray-1.1.0.tgz", + "integrity": "sha512-go+C6vHBUuO9jH6Tj6qEZ63JEGoMSxFdmd+6QAtthGD6xumBfIz3RbNDDs2aoRVLf9naH8lk5jnWy2UHHKgYQQ==", + "license": "MIT" + }, + "node_modules/cwise-compiler": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/cwise-compiler/-/cwise-compiler-1.1.3.tgz", + "integrity": "sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ==", + "license": "MIT", + "dependencies": { + "uniq": "^1.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/iota-array": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz", + "integrity": "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==", + "license": "MIT" + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/ktx-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-1.1.0.tgz", + "integrity": "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/meshoptimizer": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.2.0.tgz", + "integrity": "sha512-davRZeIJbxJrE24cwQle7ZDsxjdk/OphNOV83oX+efQinyoHY9Jcyz3MHbaoG0qySZajldGztNZ1RN/T19PZsg==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/ndarray": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ndarray/-/ndarray-1.1.1.tgz", + "integrity": "sha512-AsE8w/M2MS+0pD3nP5qbRRtJFeJJJaJ+qXZaZr0XjVGXhy6LIj2TERTlkpdK2AI3WufoOP+pwPk3q2ilAJcZ9g==", + "license": "MIT", + "dependencies": { + "iota-array": "^1.0.0", + "is-buffer": "^1.0.2" + } + }, + "node_modules/ndarray-lanczos": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/ndarray-lanczos/-/ndarray-lanczos-0.3.0.tgz", + "integrity": "sha512-5kBmmG3Zvyj77qxIAC4QFLKuYdDIBJwCG+DukT6jQHNa1Ft74/hPH1z5mbQXeHBt8yvGPBGVrr3wEOdJPYYZYg==", + "license": "MIT", + "dependencies": { + "@types/ndarray": "^1.0.11", + "ndarray": "^1.0.19" + } + }, + "node_modules/ndarray-ops": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/ndarray-ops/-/ndarray-ops-1.2.2.tgz", + "integrity": "sha512-BppWAFRjMYF7N/r6Ie51q6D4fs0iiGmeXIACKY66fLpnwIui3Wc3CXiD/30mgLbDjPpSLrsqcp3Z62+IcHZsDw==", + "license": "MIT", + "dependencies": { + "cwise-compiler": "^1.0.0" + } + }, + "node_modules/ndarray-pixels": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ndarray-pixels/-/ndarray-pixels-5.2.0.tgz", + "integrity": "sha512-lTh4tFKziAatVTa9crIsidUyn+lqujVOQpzfdBWvdFu2wo9Uo6z261lVX7SgMyP89xGmj3TMTPbbxl9YDnV4SA==", + "license": "MIT", + "dependencies": { + "@types/ndarray": "^1.0.14", + "ndarray": "^1.0.19", + "ndarray-ops": "^1.2.2", + "sharp": "^0.35.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/property-graph": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/property-graph/-/property-graph-4.1.0.tgz", + "integrity": "sha512-AvPcP7XECNWy4LGmFQ77k7un4lSKM4eS29PTvW4ck95uYeLxXPWJM7hLuBqK91FaHqCcgJvIUCuNJjjxKE7VKQ==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", + "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.147.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.6", + "@rolldown/binding-android-arm64": "1.2.6", + "@rolldown/binding-darwin-arm64": "1.2.6", + "@rolldown/binding-darwin-x64": "1.2.6", + "@rolldown/binding-freebsd-x64": "1.2.6", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", + "@rolldown/binding-linux-arm64-gnu": "1.2.6", + "@rolldown/binding-linux-arm64-musl": "1.2.6", + "@rolldown/binding-linux-ppc64-gnu": "1.2.6", + "@rolldown/binding-linux-s390x-gnu": "1.2.6", + "@rolldown/binding-linux-x64-gnu": "1.2.6", + "@rolldown/binding-linux-x64-musl": "1.2.6", + "@rolldown/binding-openharmony-arm64": "1.2.6", + "@rolldown/binding-win32-arm64-msvc": "1.2.6", + "@rolldown/binding-win32-x64-msvc": "1.2.6" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/three": { + "version": "0.185.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", + "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/uniq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", + "integrity": "sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..65dab40 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "shrinkgod", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "@gltf-transform/core": "^4.4.2", + "@gltf-transform/extensions": "^4.4.2", + "@gltf-transform/functions": "^4.4.2", + "meshoptimizer": "^1.2.0", + "three": "^0.185.1" + }, + "devDependencies": { + "vite": "^8.2.2" + } +} \ No newline at end of file diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..7167ba0 --- /dev/null +++ b/src/main.js @@ -0,0 +1,239 @@ +import { CompareViewer } from './viewer.js'; +import { analyzeBytes, optimize, PRESETS } from './optimize.js'; + +const $ = (id) => document.getElementById(id); + +const state = { + name: null, + origBytes: null, + origReport: null, + optBytes: null, + optReport: null, + viewer: null, + busy: false, +}; + +// ---- error slider is log-scale: 0.01% .. 10% of mesh radius ---- +const sliderToError = (s) => Math.pow(10, -4 + 3 * (s / 100)); +const errorToSlider = (e) => Math.round(((Math.log10(e) + 4) / 3) * 100); + +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'; +} +const fmtInt = (n) => n.toLocaleString('en-US'); + +function updateLabels() { + $('ratio-val').textContent = $('ratio').value + '%'; + const e = sliderToError(+$('error').value); + $('error-val').textContent = (e * 100).toPrecision(2) + '%'; + $('quality-val').textContent = $('quality').value; +} + +function applyPreset(name) { + const p = PRESETS[name]; + $('ratio').value = Math.round(p.ratio * 100); + $('error').value = errorToSlider(p.error); + $('max-tex').value = String(p.maxTex <= 4096 ? p.maxTex : 99999); + $('webp').checked = p.webp; + document.querySelectorAll('.preset').forEach((b) => + b.classList.toggle('active', b.dataset.preset === name) + ); + updateLabels(); +} + +function currentOpts() { + return { + ratio: +$('ratio').value / 100, + error: sliderToError(+$('error').value), + maxTex: +$('max-tex').value, + webp: $('webp').checked, + texQuality: +$('quality').value / 100, + join: $('join').checked, + quantize: $('quantize').checked, + meshopt: $('meshopt').checked, + }; +} + +function statsTable(report, base) { + const rows = [ + ['file', fmtBytes(report.bytes), base && base.bytes], + ['triangles', fmtInt(report.tris), base && base.tris], + ['vertices', fmtInt(report.verts), base && base.verts], + ['draw calls', fmtInt(report.drawCalls), base && base.drawCalls], + ['textures', `${report.textures.length} (${fmtBytes(report.texBytes)})`, base && base.texBytes], + ]; + const rawVals = [report.bytes, report.tris, report.verts, report.drawCalls, report.texBytes]; + let html = ''; + rows.forEach(([label, val, baseVal], i) => { + let delta = ''; + if (baseVal != null && baseVal > 0) { + const pct = ((rawVals[i] - baseVal) / baseVal) * 100; + const cls = pct <= 0 ? 'delta-good' : 'delta-bad'; + delta = ``; + } + html += `${delta}`; + }); + html += '
${pct > 0 ? '+' : ''}${pct.toFixed(0)}%
${label}${val}
'; + const extras = []; + if (report.animations.length) extras.push(`${report.animations.length} clip(s)`); + if (report.skins) extras.push(`${report.skins} skin(s)`); + if (report.morphPrims) extras.push(`${report.morphPrims} morph prim(s)`); + if (extras.length) html += `
${extras.join(' Β· ')}
`; + return html; +} + +function setStatus(text) { + $('status').textContent = text; +} + +async function loadFile(file) { + const bytes = await file.arrayBuffer(); + setStatus('reading…'); + let report; + try { + report = await analyzeBytes(bytes); + } catch (e) { + if (/draco/i.test(String(e))) { + alert('This GLB is Draco-compressed β€” not supported yet. Re-export without Draco (it will still come out smaller here).'); + } else { + alert('Could not read this file as a GLB:\n' + (e.message || e)); + } + setStatus(''); + return; + } + + state.name = file.name; + state.origBytes = bytes; + state.origReport = report; + state.optBytes = null; + state.optReport = null; + + $('dropzone').classList.add('hidden'); + $('viewer-ui').classList.remove('hidden'); + $('file-info').classList.remove('hidden'); + $('file-name').textContent = file.name; + $('file-meta').textContent = `${fmtBytes(report.bytes)} Β· ${fmtInt(report.tris)} tris Β· ${report.meshes} mesh(es) Β· ${report.materials} material(s)`; + + const rigged = report.skins > 0 || report.animations.length > 0; + $('rig-badge').classList.toggle('hidden', !rigged); + $('join').disabled = rigged; + if (rigged) $('join').checked = false; + + if (!state.viewer) { + state.viewer = new CompareViewer($('pane-left'), $('pane-right'), $('viewers')); + } + state.viewer.clear('right'); + $('right-empty').style.display = 'flex'; + await state.viewer.load('left', bytes.slice(0)); + + // animation UI + const names = state.viewer.clipNames(); + $('anim-row').hidden = names.length === 0; + if (names.length) { + $('anim-select').innerHTML = names + .map((n, i) => ``) + .join(''); + state.viewer.playClip(0); + } + + $('stats-left').innerHTML = statsTable(report, null); + $('stats-right').innerHTML = ''; + $('notes').classList.add('hidden'); + $('optimize').disabled = false; + $('export').disabled = true; + setStatus(''); +} + +async function runOptimize() { + if (state.busy || !state.origBytes) return; + state.busy = true; + $('optimize').disabled = true; + $('optimize').textContent = 'Crunching…'; + try { + const t0 = performance.now(); + const { bytes, report, notes } = await optimize(state.origBytes, currentOpts(), (s) => + setStatus(s + '…') + ); + const secs = ((performance.now() - t0) / 1000).toFixed(1); + state.optBytes = bytes; + state.optReport = report; + + $('right-empty').style.display = 'none'; + await state.viewer.load('right', bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)); + const sel = +($('anim-select').value || 0); + if (state.viewer.clipNames().length) state.viewer.playClip(sel); + + $('stats-right').innerHTML = statsTable(report, state.origReport); + const saved = 1 - report.bytes / state.origReport.bytes; + setStatus(`done in ${secs}s β€” ${(saved * 100).toFixed(0)}% smaller`); + + const notesEl = $('notes'); + notesEl.classList.toggle('hidden', notes.length === 0); + notesEl.innerHTML = notes.map((n) => `
Β· ${n}
`).join(''); + + $('export').disabled = false; + } catch (e) { + console.error(e); + alert('Optimize failed:\n' + (e.message || e)); + setStatus('failed'); + } finally { + state.busy = false; + $('optimize').disabled = false; + $('optimize').textContent = 'Optimize'; + } +} + +function exportGlb() { + if (!state.optBytes) return; + const base = state.name.replace(/\.glb$/i, ''); + const blob = new Blob([state.optBytes], { type: 'model/gltf-binary' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `${base}.opt.glb`; + a.click(); + URL.revokeObjectURL(a.href); +} + +// ---- wiring ---- +document.querySelectorAll('.preset').forEach((b) => + b.addEventListener('click', () => applyPreset(b.dataset.preset)) +); +['ratio', 'error', 'quality'].forEach((id) => $(id).addEventListener('input', updateLabels)); +$('optimize').addEventListener('click', runOptimize); +$('export').addEventListener('click', exportGlb); +$('browse').addEventListener('click', () => $('file-input').click()); +$('file-input').addEventListener('change', (e) => { + if (e.target.files[0]) loadFile(e.target.files[0]); +}); +$('wireframe').addEventListener('change', (e) => state.viewer?.setWireframe(e.target.checked)); +$('anim-select').addEventListener('change', (e) => state.viewer?.playClip(+e.target.value)); +$('anim-play').addEventListener('change', (e) => state.viewer?.setPlaying(e.target.checked)); + +window.addEventListener('dragover', (e) => { + e.preventDefault(); + $('dropzone').classList.add('drag'); +}); +window.addEventListener('dragleave', () => $('dropzone').classList.remove('drag')); +window.addEventListener('drop', (e) => { + e.preventDefault(); + $('dropzone').classList.remove('drag'); + const file = [...(e.dataTransfer?.files || [])].find((f) => /\.glb$/i.test(f.name)); + if (file) loadFile(file); +}); + +applyPreset('balanced'); + +// ---- dev test hook: ?test=file.glb&run=1 loads from /public and optimizes ---- +const params = new URLSearchParams(location.search); +const testFile = params.get('test'); +if (testFile) { + (async () => { + const res = await fetch('/' + testFile); + const buf = await res.arrayBuffer(); + await loadFile(new File([buf], testFile)); + if (params.get('run')) await runOptimize(); + window.__testDone = true; + })(); +} diff --git a/src/optimize.js b/src/optimize.js new file mode 100644 index 0000000..8e87500 --- /dev/null +++ b/src/optimize.js @@ -0,0 +1,274 @@ +import { WebIO } from '@gltf-transform/core'; +import { + ALL_EXTENSIONS, + EXTTextureWebP, + EXTMeshoptCompression, +} from '@gltf-transform/extensions'; +import { + dedup, + prune, + weld, + weldPrimitive, + simplifyPrimitive, + resample, + flatten, + join, + quantize, + listTextureSlots, +} from '@gltf-transform/functions'; +import { MeshoptDecoder, MeshoptEncoder, MeshoptSimplifier } from 'meshoptimizer'; + +const TRIANGLES = 4; + +let _io = null; +async function getIO() { + if (!_io) { + await Promise.all([MeshoptDecoder.ready, MeshoptEncoder.ready]); + _io = new WebIO() + .registerExtensions(ALL_EXTENSIONS) + .registerDependencies({ + 'meshopt.decoder': MeshoptDecoder, + 'meshopt.encoder': MeshoptEncoder, + }); + } + return _io; +} + +export const PRESETS = { + light: { ratio: 0.5, error: 0.005, maxTex: 2048, webp: false }, + balanced: { ratio: 0.25, error: 0.01, maxTex: 1024, webp: true }, + crunch: { ratio: 0.1, error: 0.05, maxTex: 512, webp: true }, +}; + +// Skinned meshes never go below this keep-ratio, and error is capped β€” +// collapsing verts near joints wrecks skin weights and silhouettes in motion. +const SKINNED_MIN_RATIO = 0.5; +const SKINNED_MAX_ERROR = 0.005; + +function skinnedMeshSet(root) { + const set = new Set(); + for (const node of root.listNodes()) { + if (node.getSkin() && node.getMesh()) set.add(node.getMesh()); + } + return set; +} + +export function reportDoc(doc, byteLength) { + const root = doc.getRoot(); + let tris = 0; + let verts = 0; + let prims = 0; + let morphPrims = 0; + for (const mesh of root.listMeshes()) { + for (const prim of mesh.listPrimitives()) { + prims++; + if (prim.listTargets().length > 0) morphPrims++; + const pos = prim.getAttribute('POSITION'); + if (!pos) continue; + verts += pos.getCount(); + const idx = prim.getIndices(); + const count = idx ? idx.getCount() : pos.getCount(); + if (prim.getMode() === TRIANGLES) tris += Math.floor(count / 3); + } + } + let drawCalls = 0; + for (const node of root.listNodes()) { + const mesh = node.getMesh(); + if (mesh) drawCalls += mesh.listPrimitives().length; + } + const textures = root.listTextures().map((tex) => { + let size = null; + try { size = tex.getSize(); } catch { /* unknown mime */ } + return { + name: tex.getName() || '', + mime: tex.getMimeType(), + bytes: tex.getImage() ? tex.getImage().byteLength : 0, + size, + }; + }); + return { + bytes: byteLength, + tris, + verts, + prims, + morphPrims, + drawCalls, + meshes: root.listMeshes().length, + materials: root.listMaterials().length, + textures, + texBytes: textures.reduce((s, t) => s + t.bytes, 0), + animations: root.listAnimations().map((a) => a.getName() || 'clip'), + skins: root.listSkins().length, + }; +} + +export async function analyzeBytes(bytes) { + const io = await getIO(); + const doc = await io.readBinary(new Uint8Array(bytes)); + return reportDoc(doc, bytes.byteLength); +} + +function isColorSlot(slot) { + return /base|diffuse|emissive|sheenColor|specularColor/i.test(slot); +} + +async function processTextures(doc, opts, notes) { + const textures = doc.getRoot().listTextures(); + let touched = 0; + for (const tex of textures) { + const image = tex.getImage(); + if (!image) continue; + const mime = tex.getMimeType(); + if (!/^image\/(png|jpeg|webp)$/.test(mime)) continue; // ktx2 etc: leave alone + + let bitmap; + try { + bitmap = await createImageBitmap(new Blob([image], { type: mime }), { + colorSpaceConversion: 'none', + premultiplyAlpha: 'none', + }); + } catch { + notes.push(`could not decode texture "${tex.getName() || mime}" β€” left as-is`); + continue; + } + + const longest = Math.max(bitmap.width, bitmap.height); + const scale = Math.min(1, opts.maxTex / longest); + const w = Math.max(1, Math.round(bitmap.width * scale)); + const h = Math.max(1, Math.round(bitmap.height * scale)); + const needsResize = scale < 1; + + // Data textures (normal/ORM/etc) stay lossless; color can go webp/jpeg. + const slots = listTextureSlots(tex); + const color = slots.length === 0 || slots.some(isColorSlot); + let targetMime; + if (!color) targetMime = 'image/png'; + else if (opts.webp) targetMime = 'image/webp'; + else targetMime = mime === 'image/webp' ? 'image/webp' : mime; // never png->jpeg (alpha) + + if (!needsResize && targetMime === mime && mime === 'image/png') { + bitmap.close(); + continue; // nothing to gain re-encoding png->png at same size + } + + const canvas = new OffscreenCanvas(w, h); + const ctx = canvas.getContext('2d'); + ctx.drawImage(bitmap, 0, 0, w, h); + bitmap.close(); + const lossy = targetMime !== 'image/png'; + const blob = await canvas.convertToBlob({ + type: targetMime, + quality: lossy ? opts.texQuality : undefined, + }); + const out = new Uint8Array(await blob.arrayBuffer()); + + // Keep the original if we somehow made it bigger without shrinking dims. + if (!needsResize && out.byteLength >= image.byteLength) continue; + tex.setImage(out).setMimeType(blob.type); + touched++; + } + if (doc.getRoot().listTextures().some((t) => t.getMimeType() === 'image/webp')) { + doc.createExtension(EXTTextureWebP).setRequired(true); + } + return touched; +} + +/** + * bytes: ArrayBuffer of a .glb + * opts: { ratio, error, maxTex, webp, texQuality, join, quantize, meshopt } + */ +export async function optimize(bytes, opts, onStatus = () => {}) { + const io = await getIO(); + await MeshoptSimplifier.ready; + const notes = []; + + onStatus('parsing'); + const doc = await io.readBinary(new Uint8Array(bytes.slice(0))); + const root = doc.getRoot(); + const rigged = root.listSkins().length > 0 || root.listAnimations().length > 0; + + onStatus('deduplicating'); + await doc.transform(dedup()); + + if (opts.join && !rigged) { + onStatus('joining meshes'); + try { + await doc.transform(flatten(), join()); + } catch (e) { + notes.push(`join skipped: ${e.message}`); + } + } + + onStatus('welding'); + await doc.transform(weld()); + + onStatus('simplifying geometry'); + const skinned = skinnedMeshSet(root); + let skippedMorph = 0; + let clampedSkinned = 0; + for (const mesh of root.listMeshes()) { + const isSkinned = skinned.has(mesh); + for (const prim of mesh.listPrimitives()) { + if (prim.getMode() !== TRIANGLES) continue; + if (prim.listTargets().length > 0) { + skippedMorph++; + continue; + } + let { ratio, error } = opts; + let lockBorder = false; + if (isSkinned) { + if (ratio < SKINNED_MIN_RATIO) { + ratio = SKINNED_MIN_RATIO; + clampedSkinned++; + } + error = Math.min(error, SKINNED_MAX_ERROR); + lockBorder = true; + } + try { + weldPrimitive(prim); + simplifyPrimitive(prim, { simplifier: MeshoptSimplifier, ratio, error, lockBorder }); + } catch (e) { + notes.push(`simplify skipped on "${mesh.getName() || 'mesh'}": ${e.message}`); + } + } + } + + if (root.listAnimations().length > 0) { + onStatus('resampling animations'); + try { + await doc.transform(resample()); + } catch (e) { + notes.push(`animation resample skipped: ${e.message}`); + } + } + + onStatus('pruning'); + await doc.transform(prune()); + + onStatus('processing textures'); + await processTextures(doc, opts, notes); + + if (opts.quantize || opts.meshopt) { + onStatus('quantizing'); + try { + await doc.transform(quantize()); + } catch (e) { + notes.push(`quantize skipped: ${e.message}`); + } + } + + if (opts.meshopt) { + doc + .createExtension(EXTMeshoptCompression) + .setRequired(true) + .setEncoderOptions({ method: EXTMeshoptCompression.EncoderMethod.FILTER }); + } + + if (skippedMorph) notes.push(`${skippedMorph} morph-target primitive(s) left untouched (blendshape protection)`); + if (clampedSkinned) notes.push(`${clampedSkinned} skinned primitive(s) clamped to keep β‰₯${SKINNED_MIN_RATIO * 100}% (rig protection)`); + + onStatus('writing glb'); + const out = await io.writeBinary(doc); + const report = reportDoc(doc, out.byteLength); + return { bytes: out, report, notes, rigged }; +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..a8443e4 --- /dev/null +++ b/src/style.css @@ -0,0 +1,221 @@ +* { box-sizing: border-box; margin: 0; } + +:root { + --bg: #0c0e11; + --panel: #14171c; + --panel-2: #1a1e24; + --border: #262b33; + --text: #d7dce3; + --dim: #8a93a0; + --accent: #7ee787; + --accent-dim: #2e5138; + --bad: #ff7b72; +} + +html, body { height: 100%; } +body { + background: var(--bg); + color: var(--text); + font: 14px/1.45 -apple-system, system-ui, sans-serif; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.dim { color: var(--dim); } +.tiny { font-size: 11px; } +.accent { color: var(--accent); } +.hidden, [hidden] { display: none !important; } + +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + border-bottom: 1px solid var(--border); + flex: 0 0 auto; +} +.brand { font-size: 16px; letter-spacing: 0.5px; } +.brand .sub { color: var(--dim); font-size: 12px; margin-left: 10px; } +.local-badge { + font-size: 12px; + color: var(--accent); + background: var(--accent-dim); + border-radius: 20px; + padding: 4px 12px; +} + +#layout { display: flex; flex: 1 1 auto; min-height: 0; } + +#sidebar { + width: 270px; + flex: 0 0 auto; + overflow-y: auto; + padding: 12px; + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 12px; +} + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; +} +.panel h3 { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--dim); + margin-bottom: 10px; +} + +#file-name { font-size: 13px; word-break: break-all; } +#file-meta { font-size: 12px; margin-top: 4px; } +.badge { + margin-top: 8px; + font-size: 11px; + background: #2a2416; + color: #e3b341; + border-radius: 6px; + padding: 5px 8px; +} + +.preset-row { display: flex; gap: 6px; margin-bottom: 12px; } +.preset { + flex: 1; + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 0; + cursor: pointer; + font-size: 12px; +} +.preset.active { border-color: var(--accent); color: var(--accent); } + +.slider-label { display: block; font-size: 12px; color: var(--dim); margin-top: 8px; } +.slider-label span { float: right; } +input[type="range"] { width: 100%; accent-color: var(--accent); margin-top: 4px; } + +.row { display: flex; align-items: center; gap: 8px; font-size: 13px; margin-top: 6px; } +.row.check { cursor: pointer; } +.row select { + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 3px 6px; + margin-left: auto; +} +input[type="checkbox"] { accent-color: var(--accent); } + +.big-btn { + padding: 12px; + font-size: 15px; + font-weight: 600; + border-radius: 8px; + border: none; + cursor: pointer; + background: var(--accent); + color: #08110a; +} +.big-btn.secondary { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); } +.big-btn:disabled { opacity: 0.35; cursor: default; } + +#notes { font-size: 12px; color: var(--dim); } +#notes div { margin-top: 4px; } + +#main { flex: 1 1 auto; position: relative; display: flex; min-width: 0; } + +#dropzone { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 5; +} +#dropzone.drag { background: rgba(126, 231, 135, 0.06); } +.dz-inner { + text-align: center; + border: 2px dashed var(--border); + border-radius: 16px; + padding: 60px 80px; + font-size: 18px; +} +.dz-icon { font-size: 42px; margin-bottom: 12px; } +#browse { + margin-top: 14px; + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 20px; + font-size: 14px; + cursor: pointer; +} + +#viewer-ui { flex: 1; display: flex; flex-direction: column; min-width: 0; } +#toolbar { + display: flex; + align-items: center; + gap: 20px; + padding: 8px 14px; + border-bottom: 1px solid var(--border); + flex: 0 0 auto; +} +#toolbar .row { margin-top: 0; } +#status { margin-left: auto; font-size: 12px; } + +#viewers { flex: 1; display: flex; min-height: 0; } +.pane-wrap { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + position: relative; +} +.pane-wrap + .pane-wrap { border-left: 1px solid var(--border); } +.pane-label { + position: absolute; + top: 8px; + left: 10px; + z-index: 2; + font-size: 10px; + letter-spacing: 2px; + color: var(--dim); + background: rgba(12, 14, 17, 0.7); + padding: 2px 8px; + border-radius: 4px; +} +.pane { flex: 1; min-height: 0; position: relative; } +.pane canvas { display: block; width: 100%; height: 100%; } +.pane-empty { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--dim); + gap: 4px; + z-index: 1; +} + +.stats { + flex: 0 0 auto; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + padding: 8px 12px; + border-top: 1px solid var(--border); + background: var(--panel); + min-height: 76px; +} +.stats table { width: 100%; border-collapse: collapse; } +.stats td { padding: 1px 6px 1px 0; } +.stats td.num { text-align: right; } +.delta-good { color: var(--accent); } +.delta-bad { color: var(--bad); } diff --git a/src/viewer.js b/src/viewer.js new file mode 100644 index 0000000..3edfb27 --- /dev/null +++ b/src/viewer.js @@ -0,0 +1,189 @@ +import * as THREE from 'three'; +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; +import { MeshoptDecoder } from 'meshoptimizer'; + +const BG = new THREE.Color(0x0c0e11); + +function disposeObject(obj) { + obj.traverse((child) => { + if (child.geometry) child.geometry.dispose(); + const mats = Array.isArray(child.material) ? child.material : child.material ? [child.material] : []; + for (const m of mats) { + for (const key of Object.keys(m)) { + if (m[key] && m[key].isTexture) m[key].dispose(); + } + m.dispose(); + } + }); +} + +class Pane { + constructor(el) { + this.el = el; + this.renderer = new THREE.WebGLRenderer({ antialias: true }); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this.renderer.toneMapping = THREE.ACESFilmicToneMapping; + el.appendChild(this.renderer.domElement); + + this.scene = new THREE.Scene(); + this.scene.background = BG; + const pmrem = new THREE.PMREMGenerator(this.renderer); + this.scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture; + pmrem.dispose(); + + this.grid = new THREE.GridHelper(4, 20, 0x2c3138, 0x1b1f24); + this.scene.add(this.grid); + + this.model = null; + this.mixer = null; + this.clips = []; + } + + setModel(gltf) { + if (this.model) { + this.scene.remove(this.model); + disposeObject(this.model); + } + this.model = gltf.scene; + this.clips = gltf.animations || []; + this.mixer = this.clips.length ? new THREE.AnimationMixer(this.model) : null; + this.scene.add(this.model); + } + + resize() { + const w = this.el.clientWidth; + const h = this.el.clientHeight; + if (w && h) this.renderer.setSize(w, h, false); + return w / Math.max(1, h); + } +} + +export class CompareViewer { + constructor(leftEl, rightEl, dragEl) { + this.left = new Pane(leftEl); + this.right = new Pane(rightEl); + this.panes = [this.left, this.right]; + + this.camera = new THREE.PerspectiveCamera(45, 1, 0.01, 1000); + this.camera.position.set(2, 1.5, 3); + + // One controls instance on the shared container: both panes stay in sync. + this.controls = new OrbitControls(this.camera, dragEl); + this.controls.enableDamping = true; + this.controls.dampingFactor = 0.12; + + this.loader = new GLTFLoader(); + this.loader.setMeshoptDecoder(MeshoptDecoder); + + this.clock = new THREE.Clock(); + this.playing = true; + this.wireframe = false; + this.framed = false; + + const onResize = () => { + const aspect = this.left.resize(); + this.right.resize(); + this.camera.aspect = aspect; + this.camera.updateProjectionMatrix(); + }; + new ResizeObserver(onResize).observe(leftEl); + onResize(); + + this.renderer = this.renderLoop.bind(this); + requestAnimationFrame(this.renderer); + } + + parse(arrayBuffer) { + return new Promise((resolve, reject) => { + this.loader.parse(arrayBuffer, '', resolve, reject); + }); + } + + async load(side, arrayBuffer) { + const gltf = await this.parse(arrayBuffer); + const pane = side === 'left' ? this.left : this.right; + pane.setModel(gltf); + this.applyWireframe(pane); + if (side === 'left') { + this.frame(gltf.scene); + } + return gltf; + } + + clear(side) { + const pane = side === 'left' ? this.left : this.right; + if (pane.model) { + pane.scene.remove(pane.model); + disposeObject(pane.model); + pane.model = null; + pane.mixer = null; + pane.clips = []; + } + } + + frame(object) { + const box = new THREE.Box3().setFromObject(object); + if (box.isEmpty()) return; + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const maxDim = Math.max(size.x, size.y, size.z); + const dist = maxDim * 1.6; + this.camera.position.set(center.x + dist * 0.7, center.y + dist * 0.45, center.z + dist * 0.85); + this.camera.near = maxDim / 100; + this.camera.far = maxDim * 100; + this.camera.updateProjectionMatrix(); + this.controls.target.copy(center); + this.controls.update(); + for (const pane of this.panes) { + pane.grid.position.y = box.min.y; + pane.grid.scale.setScalar(Math.max(1, maxDim / 2)); + } + this.framed = true; + } + + clipNames() { + return this.left.clips.map((c) => c.name || 'clip'); + } + + playClip(index) { + for (const pane of this.panes) { + if (!pane.mixer) continue; + pane.mixer.stopAllAction(); + if (index < 0) continue; + // Match by name first (optimized file may reorder), fall back to index. + const want = this.left.clips[index]; + const clip = + (want && pane.clips.find((c) => c.name === want.name)) || pane.clips[index]; + if (clip) pane.mixer.clipAction(clip).reset().play(); + } + } + + setPlaying(v) { + this.playing = v; + } + + applyWireframe(pane) { + if (!pane.model) return; + pane.model.traverse((child) => { + const mats = Array.isArray(child.material) ? child.material : child.material ? [child.material] : []; + for (const m of mats) if ('wireframe' in m) m.wireframe = this.wireframe; + }); + } + + setWireframe(v) { + this.wireframe = v; + this.panes.forEach((p) => this.applyWireframe(p)); + } + + renderLoop() { + const delta = this.clock.getDelta(); + this.controls.update(); + for (const pane of this.panes) { + if (pane.mixer && this.playing) pane.mixer.update(delta); + pane.renderer.render(pane.scene, this.camera); + } + requestAnimationFrame(this.renderer); + } +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..b4342fe --- /dev/null +++ b/vite.config.js @@ -0,0 +1,5 @@ +import { defineConfig } from 'vite'; +export default defineConfig({ + base: './', + server: { port: 5173, strictPort: true }, +});