diff --git a/package-lock.json b/package-lock.json index ac86fe32..19819ec3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "openai": "^6.25.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", + "sharp": "^0.34.5", "zod": "^4.3.6" }, "devDependencies": { @@ -120,6 +121,16 @@ "node": ">=18" } }, + "node_modules/@emnapi/runtime": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -753,6 +764,15 @@ "url": "https://github.com/sponsors/nzakas" } }, + "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.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -861,6 +881,54 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "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.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "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.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "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.0.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", @@ -953,6 +1021,72 @@ "@img/sharp-libvips-linux-arm64": "1.0.4" } }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, "node_modules/@img/sharp-linux-x64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", @@ -1019,6 +1153,63 @@ "@img/sharp-libvips-linuxmusl-x64": "1.0.4" } }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-win32-x64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", @@ -4428,6 +4619,335 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/sharp/node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/sharp/node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/sharp/node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/sharp/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/sharp/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/sharp/node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4840,6 +5360,13 @@ "typescript": ">=4.8.4" } }, + "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/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", diff --git a/package.json b/package.json index e5b1bed1..ede51073 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "openai": "^6.25.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", + "sharp": "^0.34.5", "zod": "^4.3.6" }, "optionalDependencies": { diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index 779bf51a..7389a503 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -36,6 +36,7 @@ import { handleMemoryCommand, handleMemoryCardAction } from '../memory/commands. import { getRepoIdentity } from '../workspace/identity.js'; import { generateQuickAck } from '../utils/quick-ack.js'; import { checkThreadRelevance } from '../utils/thread-relevance.js'; +import { compressImage } from '../utils/image-compress.js'; // 注册审批通过后的消息重新入队回调(避免 approval.ts → event-handler.ts 循环依赖) setOnApproved((chatId, userId, text, messageId, rootId, threadId) => { @@ -2274,7 +2275,11 @@ async function parseMessage(data: MessageEventData): Promise ({ + logger: { + warn: vi.fn(), + info: vi.fn(), + }, +})); + +import { compressImage } from '../image-compress.js'; + +/** + * Create a noisy raw pixel buffer and encode to JPEG. + * Random pixels create incompressible data, resulting in large files. + */ +async function createNoisyJpeg(width: number, height: number, quality = 95): Promise { + const raw = randomBytes(width * height * 3); + return sharp(raw, { raw: { width, height, channels: 3 } }) + .jpeg({ quality }) + .toBuffer(); +} + + +describe('compressImage', () => { + it('should pass through small images unchanged', async () => { + const small = await sharp({ + create: { width: 50, height: 50, channels: 3, background: { r: 128, g: 64, b: 32 } }, + }).jpeg({ quality: 80 }).toBuffer(); + expect(small.length).toBeLessThan(100 * 1024); + + const result = await compressImage(small, 'image/jpeg'); + expect(result.data).toBe(small); // same reference = unchanged + expect(result.mediaType).toBe('image/jpeg'); + }); + + it('should compress large JPEG images', async () => { + const large = await createNoisyJpeg(3000, 2000); + expect(large.length).toBeGreaterThan(100 * 1024); + + const result = await compressImage(large, 'image/jpeg'); + expect(result.data.length).toBeLessThan(large.length); + expect(result.mediaType).toBe('image/jpeg'); + + // Verify dimensions were reduced + const meta = await sharp(result.data).metadata(); + expect(Math.max(meta.width!, meta.height!)).toBeLessThanOrEqual(1536); + }); + + it('should keep PNG as PNG when result fits target', async () => { + // Solid-color PNG compresses very well — should stay PNG + const png = await sharp({ + create: { width: 2000, height: 2000, channels: 4, background: { r: 200, g: 100, b: 50, alpha: 255 } }, + }).png().toBuffer(); + + const result = await compressImage(png, 'image/png'); + expect(result.mediaType).toBe('image/png'); + expect(result.data.length).toBeLessThan(750 * 1024); + }); + + it('should handle WebP as photo (output JPEG)', async () => { + const raw = randomBytes(2000 * 1500 * 3); + const webp = await sharp(raw, { raw: { width: 2000, height: 1500, channels: 3 } }) + .webp({ quality: 95 }) + .toBuffer(); + expect(webp.length).toBeGreaterThan(100 * 1024); + + const result = await compressImage(webp, 'image/webp'); + expect(result.mediaType).toBe('image/jpeg'); + }); + + it('should extract first frame from GIF (output JPEG)', async () => { + const raw = randomBytes(800 * 800 * 4); + const gif = await sharp(raw, { raw: { width: 800, height: 800, channels: 4 } }) + .gif() + .toBuffer(); + expect(gif.length).toBeGreaterThan(100 * 1024); + + const result = await compressImage(gif, 'image/gif'); + expect(result.mediaType).toBe('image/jpeg'); + }); + + it('should gracefully fallback on corrupted buffer', async () => { + const garbage = Buffer.from('not-a-real-image-at-all-just-garbage-data'.repeat(3000)); + + const result = await compressImage(garbage, 'image/jpeg'); + expect(result.data).toBe(garbage); + expect(result.mediaType).toBe('image/jpeg'); + }); + + it('should strip EXIF metadata', async () => { + const noisy = await createNoisyJpeg(2000, 1500); + // Add EXIF metadata + const withMeta = await sharp(noisy) + .withMetadata({ exif: { IFD0: { Copyright: 'Test' } } }) + .jpeg({ quality: 95 }) + .toBuffer(); + expect(withMeta.length).toBeGreaterThan(100 * 1024); + + const beforeMeta = await sharp(withMeta).metadata(); + expect(beforeMeta.exif).toBeDefined(); + + const result = await compressImage(withMeta, 'image/jpeg'); + const outputMeta = await sharp(result.data).metadata(); + expect(outputMeta.exif).toBeUndefined(); + }); + + it('should preserve aspect ratio', async () => { + const wide = await createNoisyJpeg(4000, 1000); // 4:1 ratio + + const result = await compressImage(wide, 'image/jpeg'); + const meta = await sharp(result.data).metadata(); + + const ratio = meta.width! / meta.height!; + expect(ratio).toBeCloseTo(4.0, 0); + }); + + it('should keep output under target size for large photos', async () => { + const large = await createNoisyJpeg(4000, 3000); + expect(large.length).toBeGreaterThan(100 * 1024); + + const result = await compressImage(large, 'image/jpeg'); + expect(result.data.length).toBeLessThan(750 * 1024); + expect(result.mediaType).toBe('image/jpeg'); + }); +}); diff --git a/src/utils/image-compress.ts b/src/utils/image-compress.ts new file mode 100644 index 00000000..d55e9aeb --- /dev/null +++ b/src/utils/image-compress.ts @@ -0,0 +1,151 @@ +import sharp from 'sharp'; +import { logger } from './logger.js'; +import type { ImageAttachment } from '../claude/types.js'; + +type ImageMediaType = ImageAttachment['mediaType']; + +/** Target max raw bytes before base64 encoding (~1MB base64) */ +const TARGET_RAW_BYTES = 750 * 1024; + +/** Skip compression for images under this size */ +const COMPRESS_THRESHOLD_BYTES = 100 * 1024; + +/** Max dimension on longest side (first pass) */ +const MAX_DIMENSION = 1536; + +/** Fallback dimension if first pass still too large */ +const FALLBACK_DIMENSION = 1024; + +/** JPEG quality for photos */ +const JPEG_QUALITY = 80; + +/** More aggressive JPEG quality for second pass */ +const JPEG_QUALITY_AGGRESSIVE = 70; + +/** JPEG quality for last-resort PNG→JPEG conversion */ +const JPEG_QUALITY_FALLBACK = 85; + +export interface CompressedImage { + data: Buffer; + mediaType: ImageMediaType; +} + +/** + * Compress an image to keep base64 output under ~1MB. + * - JPEG/WebP/GIF → resize + JPEG output + * - PNG → resize + PNG output, falls back to JPEG if still too large + * - GIF → extract first frame + * - Graceful fallback: returns original on any error + */ +export async function compressImage( + buf: Buffer, + originalMediaType: ImageMediaType, +): Promise { + if (buf.length <= COMPRESS_THRESHOLD_BYTES) { + return { data: buf, mediaType: originalMediaType }; + } + + try { + return await doCompress(buf, originalMediaType); + } catch (err) { + logger.warn( + { err, originalSize: buf.length, mediaType: originalMediaType }, + 'Image compression failed, using original', + ); + return { data: buf, mediaType: originalMediaType }; + } +} + +async function doCompress( + buf: Buffer, + mediaType: ImageMediaType, +): Promise { + const meta = await sharp(buf).metadata(); + const longestSide = Math.max(meta.width ?? 0, meta.height ?? 0); + + const isPhoto = mediaType === 'image/jpeg' || mediaType === 'image/webp' || mediaType === 'image/gif'; + + if (isPhoto) { + return compressAsJpeg(buf, mediaType, longestSide); + } + + // PNG: try to keep as PNG + return compressAsPng(buf, longestSide); +} + +async function compressAsJpeg( + buf: Buffer, + mediaType: ImageMediaType, + longestSide: number, +): Promise { + // GIF: extract first frame only + const input = mediaType === 'image/gif' + ? sharp(buf, { pages: 1 }) + : sharp(buf); + + // Auto-rotate from EXIF then strip metadata + const pipeline = input.rotate(); + + const resized = longestSide > MAX_DIMENSION + ? pipeline.resize({ width: MAX_DIMENSION, height: MAX_DIMENSION, fit: 'inside' }) + : pipeline; + + const result = await resized.jpeg({ quality: JPEG_QUALITY, mozjpeg: true }).toBuffer(); + + if (result.length <= TARGET_RAW_BYTES) { + return { data: result, mediaType: 'image/jpeg' }; + } + + // Still too large → more aggressive (preserve GIF first-frame extraction) + const aggressiveInput = mediaType === 'image/gif' ? sharp(buf, { pages: 1 }) : sharp(buf); + const smaller = await aggressiveInput + .rotate() + .resize({ width: FALLBACK_DIMENSION, height: FALLBACK_DIMENSION, fit: 'inside' }) + .jpeg({ quality: JPEG_QUALITY_AGGRESSIVE, mozjpeg: true }) + .toBuffer(); + + if (smaller.length > TARGET_RAW_BYTES) { + logger.warn( + { size: smaller.length, target: TARGET_RAW_BYTES }, + 'Image still exceeds target after aggressive compression', + ); + } + + return { data: smaller, mediaType: 'image/jpeg' }; +} + +async function compressAsPng( + buf: Buffer, + longestSide: number, +): Promise { + // First pass: resize to MAX_DIMENSION + const resized = longestSide > MAX_DIMENSION + ? sharp(buf).rotate().resize({ width: MAX_DIMENSION, height: MAX_DIMENSION, fit: 'inside' }) + : sharp(buf).rotate(); + + const pngResult = await resized.png({ compressionLevel: 9 }).toBuffer(); + + if (pngResult.length <= TARGET_RAW_BYTES) { + return { data: pngResult, mediaType: 'image/png' }; + } + + // Second pass: resize to FALLBACK_DIMENSION + const smallerPng = await sharp(buf) + .rotate() + .resize({ width: FALLBACK_DIMENSION, height: FALLBACK_DIMENSION, fit: 'inside' }) + .png({ compressionLevel: 9 }) + .toBuffer(); + + if (smallerPng.length <= TARGET_RAW_BYTES) { + return { data: smallerPng, mediaType: 'image/png' }; + } + + // Last resort: convert to JPEG + const jpegFallback = await sharp(buf) + .rotate() + .resize({ width: FALLBACK_DIMENSION, height: FALLBACK_DIMENSION, fit: 'inside' }) + .jpeg({ quality: JPEG_QUALITY_FALLBACK, mozjpeg: true }) + .toBuffer(); + + return { data: jpegFallback, mediaType: 'image/jpeg' }; +}