diff --git a/README.md b/README.md index 035c488..d341efe 100644 --- a/README.md +++ b/README.md @@ -563,7 +563,7 @@ Company-scoped POS terminals. Auth via selected company (`google_auth.selected_c `move` moves a category to a new parent. Both `id` (source) and `parentId` (target) are validated via `resolveCategoryAccess` — the target must be a real category of the same authorized company, else `Parent not found`. Moving into itself or into a descendant (cycle) → `Invalid parent`. The moved category gets the next sibling `sort` under the new parent. Returns `{ id, parentId, sort }`. For `POST /api/category/:id/move`. -`addImage` accepts a full-size AVIF `Buffer`. Full image: if already AVIF and ≤1000×1000, stored as-is; otherwise scaled to max 1000×1000 (`storageDir/fullbinary`). Thumb always scaled to max 200×200 (`storageDir/thumbbinary`) via `sharp`. Assigns random `fullHash` (`pic.hash`, unique), computes `thumbhash`, links `categorypic`. Auth via `resolveCategoryAccess`. Errors: `Category not found`, `Not authorized`. +`addImage` accepts a full-size AVIF `Buffer`. Full image: if already AVIF and ≤1000×1000, stored as-is; otherwise scaled to max 1000×1000 (`storageDir/fullbinary`). Thumb always scaled to max 200×200 (`storageDir/thumbbinary`) via `wasm-image-optimization`. Assigns random `fullHash` (`pic.hash`, unique), computes `thumbhash`, links `categorypic`. Auth via `resolveCategoryAccess`. Errors: `Category not found`, `Not authorized`. `addImage` returns: @@ -601,7 +601,7 @@ Company-scoped POS terminals. Auth via selected company (`google_auth.selected_c | `orderImages` | `(googleAuthId: string, sku: string, { fullHashes: string[] })` | `{ fullHashes: string[] }` | | `delete` | `(googleAuthId: string, sku: string)` | `void` | -`addImage` accepts a full-size AVIF `Buffer`. Full image: if already AVIF and ≤1000×1000, stored as-is; otherwise scaled to max 1000×1000 (`storageDir/fullbinary`). Thumb always scaled to max 200×200 (`storageDir/thumbbinary`) via `sharp`. Assigns random `fullHash` (`pic.hash`, unique), computes `thumbhash`, links `articlepic`. Auth like `getBySku`. Errors: `Article not found`, `Not authorized`. +`addImage` accepts a full-size AVIF `Buffer`. Full image: if already AVIF and ≤1000×1000, stored as-is; otherwise scaled to max 1000×1000 (`storageDir/fullbinary`). Thumb always scaled to max 200×200 (`storageDir/thumbbinary`) via `wasm-image-optimization`. Assigns random `fullHash` (`pic.hash`, unique), computes `thumbhash`, links `articlepic`. Auth like `getBySku`. Errors: `Article not found`, `Not authorized`. `addImage` returns `ImageResult`: diff --git a/package-lock.json b/package-lock.json index 5ad8ff6..3ab412a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,9 +14,9 @@ "chalk": "^6.0.1", "dotenv": "^18.0.8", "pdfconvert-wasm": "^0.3.0", - "sharp": "^0.35.5", "skelehash-wasm": "^0.1.1", - "thumbhash": "^0.1.1" + "thumbhash": "^0.1.1", + "wasm-image-optimization": "^2.0.13" }, "devDependencies": { "better-sqlite3": "^13.0.3", @@ -339,570 +339,12 @@ "node": ">= 20.12.0" } }, - "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/@hpcc-js/wasm-graphviz": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@hpcc-js/wasm-graphviz/-/wasm-graphviz-1.30.0.tgz", "integrity": "sha512-T20IKosOZuex2qV049fQokVJy2MWv8w9Md5V/4mRKCXDf49fBCR50VD1yBlPYHaQlHPsFGRdqXN//r59vZliWQ==", "license": "Apache-2.0" }, - "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.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", - "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", - "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.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", - "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", - "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.4" - } - }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", - "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "dependencies": { - "@img/sharp-wasm32": "0.35.5" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", - "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", - "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.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", - "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", - "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.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", - "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", - "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.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", - "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", - "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.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", - "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", - "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.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", - "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", - "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.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", - "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", - "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.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", - "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", - "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.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", - "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", - "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.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", - "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", - "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.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", - "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", - "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.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", - "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", - "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.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", - "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", - "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.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", - "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", - "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.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", - "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", - "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.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", - "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", - "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.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", - "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", - "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.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", - "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", - "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.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", - "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", - "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.5", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", - "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/sharp-wasm32": "0.35.5" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", - "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", - "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.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", - "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", - "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.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", - "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", - "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/@js-joda/core": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-6.3.0.tgz", @@ -1153,15 +595,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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/dotenv": { "version": "18.0.9", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.9.tgz", @@ -1626,6 +1059,7 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -1634,55 +1068,6 @@ "node": ">=10" } }, - "node_modules/sharp": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", - "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", - "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.5", - "@img/sharp-darwin-x64": "0.35.5", - "@img/sharp-freebsd-wasm32": "0.35.5", - "@img/sharp-libvips-darwin-arm64": "1.3.4", - "@img/sharp-libvips-darwin-x64": "1.3.4", - "@img/sharp-libvips-linux-arm": "1.3.4", - "@img/sharp-libvips-linux-arm64": "1.3.4", - "@img/sharp-libvips-linux-ppc64": "1.3.4", - "@img/sharp-libvips-linux-riscv64": "1.3.4", - "@img/sharp-libvips-linux-s390x": "1.3.4", - "@img/sharp-libvips-linux-x64": "1.3.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", - "@img/sharp-libvips-linuxmusl-x64": "1.3.4", - "@img/sharp-linux-arm": "0.35.5", - "@img/sharp-linux-arm64": "0.35.5", - "@img/sharp-linux-ppc64": "0.35.5", - "@img/sharp-linux-riscv64": "0.35.5", - "@img/sharp-linux-s390x": "0.35.5", - "@img/sharp-linux-x64": "0.35.5", - "@img/sharp-linuxmusl-arm64": "0.35.5", - "@img/sharp-linuxmusl-x64": "0.35.5", - "@img/sharp-webcontainers-wasm32": "0.35.5", - "@img/sharp-win32-arm64": "0.35.5", - "@img/sharp-win32-ia32": "0.35.5", - "@img/sharp-win32-x64": "0.35.5" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -1746,7 +1131,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "devOptional": true, + "dev": true, "license": "0BSD" }, "node_modules/undici-types": { @@ -1756,6 +1141,34 @@ "dev": true, "license": "MIT" }, + "node_modules/wasm-image-optimization": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/wasm-image-optimization/-/wasm-image-optimization-2.0.13.tgz", + "integrity": "sha512-MGaFZtdxMHZyaL3VUUcNkN8qccJcy33lcXoUIaegav/y1TiK6c2aALbKAjoe4bXHFI7nDl+5+TG0e9Y8j1/h9Q==", + "license": "MIT", + "dependencies": { + "commander": "15.0.0", + "worker-lib": "2.2.1" + }, + "bin": { + "wasm-image-optimization": "dist/cli.js" + } + }, + "node_modules/wasm-image-optimization/node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/worker-lib": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/worker-lib/-/worker-lib-2.2.1.tgz", + "integrity": "sha512-CauU4vz3vZUBG3A2CY93GY94KLhbH8JtPKtuvKu2w5G07cHkZ73QkTAJ/lEHDL6arT5WDu1qpU++T0+kGDgSCg==", + "license": "MIT" + }, "node_modules/wsl-utils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.1.tgz", diff --git a/package.json b/package.json index 8b02c7c..955ed73 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,9 @@ "chalk": "^6.0.1", "dotenv": "^18.0.8", "pdfconvert-wasm": "^0.3.0", - "sharp": "^0.35.5", "skelehash-wasm": "^0.1.1", - "thumbhash": "^0.1.1" + "thumbhash": "^0.1.1", + "wasm-image-optimization": "^2.0.13" }, "peerDependencies": { "better-sqlite3": "^12.0.0", diff --git a/scripts/benchmark/workload.js b/scripts/benchmark/workload.js index 202c219..e1f8ffb 100644 --- a/scripts/benchmark/workload.js +++ b/scripts/benchmark/workload.js @@ -1,35 +1,21 @@ -import sharp from 'sharp'; +import { optimizeImage } from 'wasm-image-optimization'; /** * Creates sample PNG image buffer. */ async function makeSampleImage(width = 200, height = 200, color = { r: 50, g: 120, b: 220 }) { - return sharp({ - create: { - width, - height, - channels: 4, - background: { ...color, alpha: 1 }, - }, - }) - .png() - .toBuffer(); + const svg = ``; + const res = await optimizeImage({ image: svg, format: 'png' }); + return Buffer.from(res.data); } /** * Creates sample AVIF thumbnail buffer. */ async function makeSampleThumb(width = 64, height = 64) { - return sharp({ - create: { - width, - height, - channels: 4, - background: { r: 200, g: 60, b: 60, alpha: 1 }, - }, - }) - .avif() - .toBuffer(); + const svg = ``; + const res = await optimizeImage({ image: svg, format: 'avif' }); + return Buffer.from(res.data); } /** diff --git a/scripts/test-attributevalue-pic.js b/scripts/test-attributevalue-pic.js index dea30f3..1f03d89 100644 --- a/scripts/test-attributevalue-pic.js +++ b/scripts/test-attributevalue-pic.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import sharp from 'sharp'; +import { optimizeImage } from 'wasm-image-optimization'; import LibDashStore from '../src/index.js'; import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js'; @@ -42,16 +42,9 @@ async function run() { assert.ok(val.id > 0); // Create a 100x100 test image - const imageBuffer = await sharp({ - create: { - width: 100, - height: 100, - channels: 4, - background: { r: 25, g: 25, b: 112, alpha: 1 }, - }, - }) - .avif() - .toBuffer(); + const svg = ``; + const encoded = await optimizeImage({ image: svg, format: 'avif' }); + const imageBuffer = Buffer.from(encoded.data); console.log('1. Adding image to attribute value...'); const addResult = await store.attributevalue.addImage(googleAuthId, val.id, imageBuffer); diff --git a/scripts/test-sqlite-storage.js b/scripts/test-sqlite-storage.js index 408f581..d86f272 100644 --- a/scripts/test-sqlite-storage.js +++ b/scripts/test-sqlite-storage.js @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { mkdtempSync, existsSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import sharp from 'sharp'; +import { optimizeImage } from 'wasm-image-optimization'; import LibDashStore from '../src/index.js'; import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js'; @@ -72,16 +72,9 @@ async function runTests() { const manifestId = delivery.id; const testContent = Buffer.from('Hello SQLite Storage Engine! PDF or text content test.'); - const testThumb = await sharp({ - create: { - width: 64, - height: 64, - channels: 4, - background: { r: 255, g: 0, b: 0, alpha: 1 }, - }, - }) - .avif() - .toBuffer(); + const thumbSvg = ``; + const testThumbRes = await optimizeImage({ image: thumbSvg, format: 'avif' }); + const testThumb = Buffer.from(testThumbRes.data); const attachment = await store.manifestattachment.add(googleAuthId, manifestId, { name: 'test-doc.txt', @@ -202,16 +195,9 @@ async function runTests() { console.log('Article track_stock tests passed!'); - const sampleImage = await sharp({ - create: { - width: 300, - height: 200, - channels: 4, - background: { r: 0, g: 128, b: 255, alpha: 1 }, - }, - }) - .png() - .toBuffer(); + const sampleSvg = ``; + const sampleImageRes = await optimizeImage({ image: sampleSvg, format: 'png' }); + const sampleImage = Buffer.from(sampleImageRes.data); const picResult = await store.article.addImage(googleAuthId, article.sku, sampleImage); const fullHash = picResult.fullHash; @@ -223,10 +209,10 @@ async function runTests() { assert.equal(smallThumbRow.smallthumbw, 32); assert.equal(smallThumbRow.smallthumbh, 21); assert(Buffer.isBuffer(smallThumbRow.smallthumbbinary), 'Small thumb AVIF must be stored in the pic row'); - const smallThumbMetadata = await sharp(smallThumbRow.smallthumbbinary).metadata(); - assert.equal(smallThumbMetadata.format, 'heif'); - assert.equal(smallThumbMetadata.width, 32); - assert.equal(smallThumbMetadata.height, 21); + const smallThumbMetadata = await optimizeImage({ image: smallThumbRow.smallthumbbinary, format: 'none' }); + assert.equal(smallThumbMetadata.originalFormat, 'avif'); + assert.equal(smallThumbMetadata.originalWidth, 32); + assert.equal(smallThumbMetadata.originalHeight, 21); const brand = await store.brand.create(googleAuthId, { name: 'Small Thumb Test Brand' }); await store.brand.addImage(googleAuthId, brand.id, sampleImage); diff --git a/src/store/article/deleteImage.js b/src/store/article/deleteImage.js index 348b26a..91986b1 100644 --- a/src/store/article/deleteImage.js +++ b/src/store/article/deleteImage.js @@ -1,4 +1,4 @@ -import sharp from 'sharp'; +import { optimizeImage } from 'wasm-image-optimization'; import { encodeRGBA } from 'skelehash-wasm'; import { DELETE_BY_ARTICLE_AND_HASH as MSSQL_QUERY } from '../../providers/mssql/queries/pic.js'; import { DELETE_BY_ARTICLE_AND_HASH as SQLITE_QUERY } from '../../providers/sqlite3/queries/pic.js'; @@ -49,8 +49,8 @@ export default function deleteImage(provider) { let skelehash = null; if (thumbBuffer) { - const { data, info } = await sharp(thumbBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); - const res = await encodeRGBA(info.width, info.height, data); + const raw = await optimizeImage({ image: thumbBuffer, format: 'raw' }); + const res = await encodeRGBA(raw.width, raw.height, raw.data); skelehash = res?.base62; } diff --git a/src/store/category/generateImage.js b/src/store/category/generateImage.js index caff921..bafdb30 100644 --- a/src/store/category/generateImage.js +++ b/src/store/category/generateImage.js @@ -9,7 +9,7 @@ import { import resolveCategoryAccess from './resolveCategoryAccess.js'; import addImage from './addImage.js'; import makeRunner from '../runner.js'; -import sharp from 'sharp'; +import { optimizeImage } from 'wasm-image-optimization'; export default function generateImage(provider) { const access = resolveCategoryAccess(provider); @@ -148,32 +148,32 @@ export default function generateImage(provider) { const TOTAL_SIZE = 1200; const tileSize = TOTAL_SIZE / targetSize; - const composites = await Promise.all( + const tiles = await Promise.all( finalPics.map(async (pic, idx) => { const row = Math.floor(idx / targetSize); const col = idx % targetSize; - const tileBuffer = await sharp(pic.buffer) - .resize(tileSize, tileSize, { fit: 'cover', position: 'center' }) - .toBuffer(); - return { - input: tileBuffer, - left: col * tileSize, - top: row * tileSize, - }; + const tile = await optimizeImage({ + image: pic.buffer, + width: tileSize, + height: tileSize, + fit: 'cover', + format: 'png', + }); + const b64 = Buffer.from(tile.data).toString('base64'); + return ``; }), ); - const mosaicBuffer = await sharp({ - create: { - width: TOTAL_SIZE, - height: TOTAL_SIZE, - channels: 4, - background: { r: 255, g: 255, b: 255, alpha: 1 }, - }, - }) - .composite(composites) - .png() - .toBuffer(); + const mosaicSvg = + `` + + `` + + tiles.join('') + + ``; + const mosaicRes = await optimizeImage({ + image: mosaicSvg, + format: 'png', + }); + const mosaicBuffer = Buffer.from(mosaicRes.data); // Clear existing categorypic rows so the new mosaic becomes the main (first) image await runner.query('DELETE FROM categorypic WHERE category_id = @categoryId', { categoryId }); diff --git a/src/store/manifestattachment/add.js b/src/store/manifestattachment/add.js index 9bf551f..a9b3773 100644 --- a/src/store/manifestattachment/add.js +++ b/src/store/manifestattachment/add.js @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import sharp from 'sharp'; +import { optimizeImage } from 'wasm-image-optimization'; import generateDocumentThumb from '../../util/generateDocumentThumb.js'; import * as mssqlQueries from '../../providers/mssql/queries/manifestattachment.js'; import * as sqliteQueries from '../../providers/sqlite3/queries/manifestattachment.js'; @@ -118,9 +118,9 @@ export default function add(provider) { if (thumbavif && (!thumbx || !thumby)) { try { - const meta = await sharp(thumbavif).metadata(); - thumbx = thumbx ?? meta.width ?? null; - thumby = thumby ?? meta.height ?? null; + const meta = await optimizeImage({ image: thumbavif, format: 'none' }); + thumbx = thumbx ?? meta.originalWidth ?? null; + thumby = thumby ?? meta.originalHeight ?? null; } catch { // ignore } diff --git a/src/util/generateDocumentThumb.js b/src/util/generateDocumentThumb.js index 6ca1c51..8372c77 100644 --- a/src/util/generateDocumentThumb.js +++ b/src/util/generateDocumentThumb.js @@ -1,4 +1,4 @@ -import sharp from 'sharp'; +import { optimizeImage } from 'wasm-image-optimization'; import { pdfToPng } from 'pdfconvert-wasm'; const THUMB_MAX = 200; @@ -97,18 +97,27 @@ export default async function generateDocumentThumb(content, mimetype, options = } try { - const thumbResult = await sharp(imageBuffer) - .resize(THUMB_MAX, THUMB_MAX, { fit: 'inside', withoutEnlargement: true }) - .avif({ effort: 4 }) - .toBuffer({ resolveWithObject: true }); + const meta = await optimizeImage({ image: imageBuffer, format: 'none' }); + const srcW = meta.originalWidth || 0; + const srcH = meta.originalHeight || 0; + const needsResize = srcW > THUMB_MAX || srcH > THUMB_MAX; + const scale = needsResize ? Math.min(THUMB_MAX / srcW, THUMB_MAX / srcH) : 1; + const thumbResult = await optimizeImage({ + image: imageBuffer, + width: needsResize ? Math.max(1, Math.round(srcW * scale)) : undefined, + height: needsResize ? Math.max(1, Math.round(srcH * scale)) : undefined, + fit: 'fill', + format: 'avif', + speed: 6, + }); return { - thumbavif: thumbResult.data, - thumbx: thumbResult.info.width, - thumby: thumbResult.info.height, + thumbavif: Buffer.from(thumbResult.data), + thumbx: thumbResult.width, + thumby: thumbResult.height, }; } catch (err) { - console.warn('generateDocumentThumb: sharp thumbnail generation failed:', err.message); + console.warn('generateDocumentThumb: thumbnail generation failed:', err.message); return { thumbavif: null, thumbx: null, thumby: null }; } } diff --git a/src/util/processAvifImage.js b/src/util/processAvifImage.js index 7d13909..a921849 100644 --- a/src/util/processAvifImage.js +++ b/src/util/processAvifImage.js @@ -1,4 +1,4 @@ -import sharp from 'sharp'; +import { optimizeImage } from 'wasm-image-optimization'; import { rgbaToThumbHash } from 'thumbhash'; import { encodeRGBA } from 'skelehash-wasm'; @@ -6,63 +6,105 @@ const FULL_MAX = 1000; const THUMB_MAX = 200; const SMALL_THUMB_MAX = 32; +function fitInsideDims(srcW, srcH, maxW, maxH) { + if (!srcW || !srcH || (srcW <= maxW && srcH <= maxH)) { + return {}; + } + const scale = Math.min(maxW / srcW, maxH / srcH); + return { + width: Math.max(1, Math.round(srcW * scale)), + height: Math.max(1, Math.round(srcH * scale)), + fit: 'fill', + }; +} + +function unpremultiplyRgba(data) { + const out = new Uint8Array(data.length); + for (let i = 0; i < data.length; i += 4) { + const a = data[i + 3]; + if (a === 255 || a === 0) { + out[i] = data[i]; + out[i + 1] = data[i + 1]; + out[i + 2] = data[i + 2]; + out[i + 3] = a; + } else { + const f = 255 / a; + out[i] = Math.min(255, Math.round(data[i] * f)); + out[i + 1] = Math.min(255, Math.round(data[i + 1] * f)); + out[i + 2] = Math.min(255, Math.round(data[i + 2] * f)); + out[i + 3] = a; + } + } + return out; +} + export default async function processAvifImage(inputBuffer) { const input = Buffer.isBuffer(inputBuffer) ? inputBuffer : Buffer.from(inputBuffer); - const meta = await sharp(input).metadata(); - const w = meta.width ?? 0; - const h = meta.height ?? 0; - const isAvif = - meta.format === 'avif' || - (meta.format === 'heif' && meta.compression === 'av1'); + const meta = await optimizeImage({ image: input, format: 'none' }); + const w = meta.originalWidth ?? 0; + const h = meta.originalHeight ?? 0; + const isAvif = meta.originalFormat === 'avif'; const useOriginalFull = isAvif && w > 0 && h > 0 && w <= FULL_MAX && h <= FULL_MAX; if (!useOriginalFull) { console.log( - `processAvifImage: converting full image (format=${meta.format ?? 'unknown'}, ${w}×${h}, avif=${isAvif})`, + `processAvifImage: converting full image (format=${meta.originalFormat ?? 'unknown'}, ${w}×${h}, avif=${isAvif})`, ); } - const thumbPipeline = sharp(input) - .resize(THUMB_MAX, THUMB_MAX, { fit: 'inside', withoutEnlargement: true }); - const smallThumbPipeline = sharp(input) - .resize(SMALL_THUMB_MAX, SMALL_THUMB_MAX, { fit: 'inside', withoutEnlargement: true }); + const fullDims = fitInsideDims(w, h, FULL_MAX, FULL_MAX); + const thumbDims = fitInsideDims(w, h, THUMB_MAX, THUMB_MAX); + const rgbaDims = fitInsideDims(w, h, 100, 100); + const smallThumbDims = fitInsideDims(w, h, SMALL_THUMB_MAX, SMALL_THUMB_MAX); const [full, thumb, thumbRgba, smallThumb] = await Promise.all([ useOriginalFull ? Promise.resolve({ - info: { width: w, height: h }, + width: w, + height: h, data: input, }) - : sharp(input) - .resize(FULL_MAX, FULL_MAX, { fit: 'inside', withoutEnlargement: true }) - .avif({ effort: 2 }) - .toBuffer({ resolveWithObject: true }), - thumbPipeline.clone().avif({ effort: 2 }).toBuffer({ resolveWithObject: true }), - thumbPipeline - .clone() - .resize(100, 100, { fit: 'inside', withoutEnlargement: true }) - .ensureAlpha() - .raw() - .toBuffer({ resolveWithObject: true }), - smallThumbPipeline - .avif({ effort: 2 }) - .toBuffer({ resolveWithObject: true }), + : optimizeImage({ + image: input, + ...fullDims, + format: 'avif', + speed: 8, + }), + optimizeImage({ + image: input, + ...thumbDims, + format: 'avif', + speed: 8, + }), + optimizeImage({ + image: input, + ...rgbaDims, + format: 'raw', + }), + optimizeImage({ + image: input, + ...smallThumbDims, + format: 'avif', + speed: 8, + }), ]); + const rgbaPixels = unpremultiplyRgba(thumbRgba.data); + const [thumbHashBytes, skele] = await Promise.all([ rgbaToThumbHash( - thumbRgba.info.width, - thumbRgba.info.height, - thumbRgba.data, + thumbRgba.width, + thumbRgba.height, + rgbaPixels, ), encodeRGBA( - thumbRgba.info.width, - thumbRgba.info.height, - thumbRgba.data, + thumbRgba.width, + thumbRgba.height, + rgbaPixels, ), ]); @@ -70,24 +112,28 @@ export default async function processAvifImage(inputBuffer) { throw new Error('encodeRGBA failed to produce a base62 skelehash'); } + const fullBuf = Buffer.isBuffer(full.data) ? full.data : Buffer.from(full.data); + const thumbBuf = Buffer.from(thumb.data); + const smallThumbBuf = Buffer.from(smallThumb.data); + return { full: { - w: full.info.width, - h: full.info.height, - size: full.data.length, - buffer: full.data, + w: full.width, + h: full.height, + size: fullBuf.length, + buffer: fullBuf, }, thumb: { - w: thumb.info.width, - h: thumb.info.height, - size: thumb.data.length, - buffer: thumb.data, + w: thumb.width, + h: thumb.height, + size: thumbBuf.length, + buffer: thumbBuf, hash: Buffer.from(thumbHashBytes).toString('base64'), }, smallThumb: { - w: smallThumb.info.width, - h: smallThumb.info.height, - buffer: smallThumb.data, + w: smallThumb.width, + h: smallThumb.height, + buffer: smallThumbBuf, }, skelehash: skele.base62, }; diff --git a/test/helpers.js b/test/helpers.js index 4d580c5..27dd89e 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -1,7 +1,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import sharp from 'sharp'; +import { optimizeImage } from 'wasm-image-optimization'; import LibDashStore from '../src/index.js'; import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js'; @@ -79,11 +79,9 @@ export async function createCustomer(store, tenant, name = 'Customer') { /** Generates a small solid-color PNG buffer for image tests. */ export async function makePng({ width = 64, height = 48, r = 200, g = 30, b = 60 } = {}) { - return sharp({ - create: { width, height, channels: 3, background: { r, g, b } }, - }) - .png() - .toBuffer(); + const svg = ``; + const res = await optimizeImage({ image: svg, format: 'png' }); + return Buffer.from(res.data); } export const isoNow = () => new Date().toISOString();