fix: compress images over 5MB before sending to Anthropic API

- Add sharp library for image compression
- Create image compression utility with progressive quality reduction
- Automatically compress images larger than 5MB in selectImages()
- Show progress notification during compression
- Add comprehensive tests for compression logic
- Convert PNG to JPEG for better compression when needed
- Resize images as last resort if compression alone fails

Fixes #8771
This commit is contained in:
Roo Code 2025-10-22 11:40:01 +00:00
parent 6fa6c3c1e6
commit 429ccd06af
5 changed files with 687 additions and 3 deletions

266
pnpm-lock.yaml generated
View file

@ -779,6 +779,9 @@ importers:
serialize-error:
specifier: ^12.0.0
version: 12.0.0
sharp:
specifier: ^0.34.4
version: 0.34.4
simple-git:
specifier: ^3.27.0
version: 3.27.0
@ -1652,6 +1655,9 @@ packages:
'@emnapi/runtime@1.4.3':
resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==}
'@emnapi/runtime@1.6.0':
resolution: {integrity: sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==}
'@emnapi/wasi-threads@1.0.2':
resolution: {integrity: sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==}
@ -1928,111 +1934,237 @@ packages:
'@iconify/utils@2.3.0':
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
'@img/colour@1.0.0':
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.33.5':
resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-arm64@0.34.4':
resolution: {integrity: sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.33.5':
resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.4':
resolution: {integrity: sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.0.4':
resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.3':
resolution: {integrity: sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.0.4':
resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.3':
resolution: {integrity: sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.0.4':
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linux-arm64@1.2.3':
resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linux-arm@1.0.5':
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
cpu: [arm]
os: [linux]
'@img/sharp-libvips-linux-arm@1.2.3':
resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==}
cpu: [arm]
os: [linux]
'@img/sharp-libvips-linux-ppc64@1.2.3':
resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==}
cpu: [ppc64]
os: [linux]
'@img/sharp-libvips-linux-s390x@1.0.4':
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
cpu: [s390x]
os: [linux]
'@img/sharp-libvips-linux-s390x@1.2.3':
resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==}
cpu: [s390x]
os: [linux]
'@img/sharp-libvips-linux-x64@1.0.4':
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
cpu: [x64]
os: [linux]
'@img/sharp-libvips-linux-x64@1.2.3':
resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==}
cpu: [x64]
os: [linux]
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linuxmusl-arm64@1.2.3':
resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
cpu: [x64]
os: [linux]
'@img/sharp-libvips-linuxmusl-x64@1.2.3':
resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==}
cpu: [x64]
os: [linux]
'@img/sharp-linux-arm64@0.33.5':
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linux-arm64@0.34.4':
resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linux-arm@0.33.5':
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
'@img/sharp-linux-arm@0.34.4':
resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
'@img/sharp-linux-ppc64@0.34.4':
resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
'@img/sharp-linux-s390x@0.33.5':
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
'@img/sharp-linux-s390x@0.34.4':
resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
'@img/sharp-linux-x64@0.33.5':
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-linux-x64@0.34.4':
resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-linuxmusl-arm64@0.33.5':
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linuxmusl-arm64@0.34.4':
resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linuxmusl-x64@0.33.5':
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-linuxmusl-x64@0.34.4':
resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-wasm32@0.33.5':
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-wasm32@0.34.4':
resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.4':
resolution: {integrity: sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.33.5':
resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-ia32@0.34.4':
resolution: {integrity: sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.33.5':
resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@img/sharp-win32-x64@0.34.4':
resolution: {integrity: sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@inquirer/external-editor@1.0.1':
resolution: {integrity: sha512-Oau4yL24d2B5IL4ma4UpbQigkVhzPDXLoqy1ggK4gnHg/stmkffJE4oOXHXF3uz0UEpywG68KcyXsyYpA1Re/Q==}
engines: {node: '>=18'}
@ -5353,6 +5485,10 @@ packages:
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
engines: {node: '>=8'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
@ -8828,6 +8964,10 @@ packages:
resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
sharp@0.34.4:
resolution: {integrity: sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@ -11180,6 +11320,11 @@ snapshots:
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.6.0':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/wasi-threads@1.0.2':
dependencies:
tslib: 2.8.1
@ -11414,81 +11559,169 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@img/colour@1.0.0': {}
'@img/sharp-darwin-arm64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.0.4
optional: true
'@img/sharp-darwin-arm64@0.34.4':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.3
optional: true
'@img/sharp-darwin-x64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.0.4
optional: true
'@img/sharp-darwin-x64@0.34.4':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.3
optional: true
'@img/sharp-libvips-darwin-arm64@1.0.4':
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.3':
optional: true
'@img/sharp-libvips-darwin-x64@1.0.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.3':
optional: true
'@img/sharp-libvips-linux-arm64@1.0.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.3':
optional: true
'@img/sharp-libvips-linux-arm@1.0.5':
optional: true
'@img/sharp-libvips-linux-arm@1.2.3':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.3':
optional: true
'@img/sharp-libvips-linux-s390x@1.0.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.3':
optional: true
'@img/sharp-libvips-linux-x64@1.0.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.3':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.3':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.3':
optional: true
'@img/sharp-linux-arm64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.0.4
optional: true
'@img/sharp-linux-arm64@0.34.4':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.3
optional: true
'@img/sharp-linux-arm@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.0.5
optional: true
'@img/sharp-linux-arm@0.34.4':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.3
optional: true
'@img/sharp-linux-ppc64@0.34.4':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.3
optional: true
'@img/sharp-linux-s390x@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.0.4
optional: true
'@img/sharp-linux-s390x@0.34.4':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.3
optional: true
'@img/sharp-linux-x64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.0.4
optional: true
'@img/sharp-linux-x64@0.34.4':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.3
optional: true
'@img/sharp-linuxmusl-arm64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.0.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.4':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.3
optional: true
'@img/sharp-linuxmusl-x64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.0.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.4':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.3
optional: true
'@img/sharp-wasm32@0.33.5':
dependencies:
'@emnapi/runtime': 1.4.3
optional: true
'@img/sharp-wasm32@0.34.4':
dependencies:
'@emnapi/runtime': 1.6.0
optional: true
'@img/sharp-win32-arm64@0.34.4':
optional: true
'@img/sharp-win32-ia32@0.33.5':
optional: true
'@img/sharp-win32-ia32@0.34.4':
optional: true
'@img/sharp-win32-x64@0.33.5':
optional: true
'@img/sharp-win32-x64@0.34.4':
optional: true
'@inquirer/external-editor@1.0.1(@types/node@24.2.1)':
dependencies:
chardet: 2.1.0
@ -13919,7 +14152,7 @@ snapshots:
sirv: 3.0.1
tinyglobby: 0.2.14
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
'@vitest/utils@3.2.4':
dependencies:
@ -15106,6 +15339,8 @@ snapshots:
detect-libc@2.0.4: {}
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
devlop@1.1.0:
@ -19283,6 +19518,35 @@ snapshots:
'@img/sharp-win32-x64': 0.33.5
optional: true
sharp@0.34.4:
dependencies:
'@img/colour': 1.0.0
detect-libc: 2.1.2
semver: 7.7.2
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.4
'@img/sharp-darwin-x64': 0.34.4
'@img/sharp-libvips-darwin-arm64': 1.2.3
'@img/sharp-libvips-darwin-x64': 1.2.3
'@img/sharp-libvips-linux-arm': 1.2.3
'@img/sharp-libvips-linux-arm64': 1.2.3
'@img/sharp-libvips-linux-ppc64': 1.2.3
'@img/sharp-libvips-linux-s390x': 1.2.3
'@img/sharp-libvips-linux-x64': 1.2.3
'@img/sharp-libvips-linuxmusl-arm64': 1.2.3
'@img/sharp-libvips-linuxmusl-x64': 1.2.3
'@img/sharp-linux-arm': 0.34.4
'@img/sharp-linux-arm64': 0.34.4
'@img/sharp-linux-ppc64': 0.34.4
'@img/sharp-linux-s390x': 0.34.4
'@img/sharp-linux-x64': 0.34.4
'@img/sharp-linuxmusl-arm64': 0.34.4
'@img/sharp-linuxmusl-x64': 0.34.4
'@img/sharp-wasm32': 0.34.4
'@img/sharp-win32-arm64': 0.34.4
'@img/sharp-win32-ia32': 0.34.4
'@img/sharp-win32-x64': 0.34.4
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0

View file

@ -0,0 +1,277 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import sharp from "sharp"
import { compressImageIfNeeded, needsCompression, formatFileSize } from "../image-compression"
vi.mock("sharp")
describe("image-compression", () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe("needsCompression", () => {
it("should return false for images under 5MB", () => {
const smallBuffer = Buffer.alloc(3 * 1024 * 1024) // 3MB
expect(needsCompression(smallBuffer)).toBe(false)
})
it("should return true for images over 5MB", () => {
const largeBuffer = Buffer.alloc(6 * 1024 * 1024) // 6MB
expect(needsCompression(largeBuffer)).toBe(true)
})
it("should return false for images exactly 5MB", () => {
const exactBuffer = Buffer.alloc(5 * 1024 * 1024) // 5MB
expect(needsCompression(exactBuffer)).toBe(false)
})
})
describe("formatFileSize", () => {
it("should format bytes to MB correctly", () => {
expect(formatFileSize(1024 * 1024)).toBe("1.0 MB")
expect(formatFileSize(5.5 * 1024 * 1024)).toBe("5.5 MB")
expect(formatFileSize(10.25 * 1024 * 1024)).toBe("10.3 MB")
})
})
describe("compressImageIfNeeded", () => {
it("should return original buffer if under 5MB", async () => {
const smallBuffer = Buffer.alloc(3 * 1024 * 1024) // 3MB
const mimeType = "image/jpeg"
const result = await compressImageIfNeeded(smallBuffer, mimeType)
expect(result.buffer).toBe(smallBuffer)
expect(result.mimeType).toBe(mimeType)
expect(sharp).not.toHaveBeenCalled()
})
it("should compress JPEG images over 5MB", async () => {
const largeBuffer = Buffer.alloc(6 * 1024 * 1024) // 6MB
const compressedBuffer = Buffer.alloc(4 * 1024 * 1024) // 4MB
const mimeType = "image/jpeg"
const mockMetadata = {
format: "jpeg",
width: 4000,
height: 3000,
}
const mockSharp = {
metadata: vi.fn().mockResolvedValue(mockMetadata),
jpeg: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(compressedBuffer),
}
vi.mocked(sharp).mockReturnValue(mockSharp as any)
const result = await compressImageIfNeeded(largeBuffer, mimeType)
expect(sharp).toHaveBeenCalledWith(largeBuffer)
expect(mockSharp.jpeg).toHaveBeenCalledWith({
quality: expect.any(Number),
mozjpeg: true,
})
expect(result.buffer).toBe(compressedBuffer)
expect(result.mimeType).toBe("image/jpeg")
}, 30000) // Increase timeout to 30 seconds
it("should convert PNG to JPEG for better compression", async () => {
const largeBuffer = Buffer.alloc(6 * 1024 * 1024) // 6MB
const compressedBuffer = Buffer.alloc(4 * 1024 * 1024) // 4MB
const mimeType = "image/png"
const mockMetadata = {
format: "png",
width: 4000,
height: 3000,
}
const mockSharp = {
metadata: vi.fn().mockResolvedValue(mockMetadata),
jpeg: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(compressedBuffer),
}
vi.mocked(sharp).mockReturnValue(mockSharp as any)
const result = await compressImageIfNeeded(largeBuffer, mimeType)
expect(mockSharp.jpeg).toHaveBeenCalled()
expect(result.buffer).toBe(compressedBuffer)
expect(result.mimeType).toBe("image/jpeg") // Should be converted to JPEG
})
it("should compress WebP images", async () => {
const largeBuffer = Buffer.alloc(6 * 1024 * 1024) // 6MB
const compressedBuffer = Buffer.alloc(4 * 1024 * 1024) // 4MB
const mimeType = "image/webp"
const mockMetadata = {
format: "webp",
width: 4000,
height: 3000,
}
const mockSharp = {
metadata: vi.fn().mockResolvedValue(mockMetadata),
webp: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(compressedBuffer),
}
vi.mocked(sharp).mockReturnValue(mockSharp as any)
const result = await compressImageIfNeeded(largeBuffer, mimeType)
expect(mockSharp.webp).toHaveBeenCalledWith({
quality: expect.any(Number),
})
expect(result.buffer).toBe(compressedBuffer)
expect(result.mimeType).toBe("image/webp")
})
it("should progressively reduce quality if initial compression is insufficient", async () => {
const largeBuffer = Buffer.alloc(8 * 1024 * 1024) // 8MB
const stillLargeBuffer = Buffer.alloc(5.5 * 1024 * 1024) // 5.5MB
const compressedBuffer = Buffer.alloc(4 * 1024 * 1024) // 4MB
const mimeType = "image/jpeg"
const mockMetadata = {
format: "jpeg",
width: 4000,
height: 3000,
}
const mockSharp = {
metadata: vi.fn().mockResolvedValue(mockMetadata),
jpeg: vi.fn().mockReturnThis(),
toBuffer: vi
.fn()
.mockResolvedValueOnce(stillLargeBuffer) // First attempt still too large
.mockResolvedValueOnce(compressedBuffer), // Second attempt successful
}
vi.mocked(sharp).mockReturnValue(mockSharp as any)
const result = await compressImageIfNeeded(largeBuffer, mimeType)
expect(mockSharp.toBuffer).toHaveBeenCalledTimes(2)
expect(result.buffer).toBe(compressedBuffer)
})
it("should resize image as last resort if compression alone fails", async () => {
const largeBuffer = Buffer.alloc(15 * 1024 * 1024) // 15MB
const stillLargeBuffer = Buffer.alloc(6 * 1024 * 1024) // 6MB (still too large)
const resizedBuffer = Buffer.alloc(4 * 1024 * 1024) // 4MB
const mimeType = "image/jpeg"
const mockMetadata = {
format: "jpeg",
width: 4000,
height: 3000,
}
// Track the number of sharp calls
let sharpCallCount = 0
// Mock for metadata call
const mockMetadataSharp = {
metadata: vi.fn().mockResolvedValue(mockMetadata),
}
// Mock for compression attempts (will fail)
const mockCompressSharp = {
metadata: vi.fn().mockResolvedValue(mockMetadata),
jpeg: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(stillLargeBuffer), // Still too large
}
// Mock for resize operation
const mockResizeSharp = {
resize: vi.fn().mockReturnThis(),
jpeg: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(resizedBuffer),
}
vi.mocked(sharp).mockImplementation(() => {
sharpCallCount++
// First call is for metadata
if (sharpCallCount === 1) {
return mockMetadataSharp as any
}
// Next 5 calls are compression attempts
else if (sharpCallCount <= 6) {
return mockCompressSharp as any
}
// Last call is for resize
else {
return mockResizeSharp as any
}
})
const result = await compressImageIfNeeded(largeBuffer, mimeType)
// Verify resize was called
expect(mockResizeSharp.resize).toHaveBeenCalledWith({
width: expect.any(Number),
height: expect.any(Number),
fit: "inside",
withoutEnlargement: true,
})
expect(result.buffer).toBe(resizedBuffer)
// Verify the correct number of sharp calls were made
expect(sharpCallCount).toBe(7) // 1 metadata + 5 compression attempts + 1 resize
}, 30000) // Increase timeout to 30 seconds
it("should handle compression errors gracefully", async () => {
const largeBuffer = Buffer.alloc(6 * 1024 * 1024) // 6MB
const mimeType = "image/jpeg"
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
vi.mocked(sharp).mockImplementation(() => {
throw new Error("Sharp processing failed")
})
const result = await compressImageIfNeeded(largeBuffer, mimeType)
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to compress image:", expect.any(Error))
expect(result.buffer).toBe(largeBuffer) // Should return original
expect(result.mimeType).toBe(mimeType)
consoleErrorSpy.mockRestore()
})
it("should calculate quality based on compression ratio", async () => {
const largeBuffer = Buffer.alloc(7 * 1024 * 1024) // 7MB
const compressedBuffer = Buffer.alloc(4 * 1024 * 1024) // 4MB
const mimeType = "image/jpeg"
const mockMetadata = {
format: "jpeg",
width: 4000,
height: 3000,
}
const mockSharp = {
metadata: vi.fn().mockResolvedValue(mockMetadata),
jpeg: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(compressedBuffer),
}
vi.mocked(sharp).mockReturnValue(mockSharp as any)
await compressImageIfNeeded(largeBuffer, mimeType)
// Target size is 90% of 5MB = 4.5MB
// Compression ratio = 4.5MB / 7MB ≈ 0.64
// Quality = 0.64 * 100 = 64
expect(mockSharp.jpeg).toHaveBeenCalledWith({
quality: 64,
mozjpeg: true,
})
})
})
})

View file

@ -0,0 +1,117 @@
import sharp from "sharp"
/**
* Maximum file size in bytes before compression is applied (5MB)
*/
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
/**
* Compresses an image if it exceeds the maximum file size.
*
* @param buffer - The image buffer to potentially compress
* @param mimeType - The MIME type of the image
* @returns The compressed buffer and updated MIME type, or original if compression not needed
*/
export async function compressImageIfNeeded(
buffer: Buffer,
mimeType: string,
): Promise<{ buffer: Buffer; mimeType: string }> {
// If the image is smaller than 5MB, return as-is
if (buffer.length <= MAX_FILE_SIZE) {
return { buffer, mimeType }
}
try {
// Use sharp to compress the image
let sharpInstance = sharp(buffer)
// Get image metadata to determine format
const metadata = await sharpInstance.metadata()
// Calculate compression quality based on how much we need to reduce
const targetSize = MAX_FILE_SIZE * 0.9 // Target 90% of max size to leave some margin
const compressionRatio = targetSize / buffer.length
// Start with a quality based on the compression ratio
// Lower quality for larger compression needs
let quality = Math.max(20, Math.min(95, Math.round(compressionRatio * 100)))
let compressedBuffer: Buffer
let attempts = 0
const maxAttempts = 5
// Try to compress with progressively lower quality until we get under the limit
while (attempts < maxAttempts) {
attempts++
// Reset sharp instance for each attempt
sharpInstance = sharp(buffer)
// Apply format-specific compression
if (mimeType === "image/png" || metadata.format === "png") {
// For PNG, convert to JPEG for better compression if needed
compressedBuffer = await sharpInstance.jpeg({ quality, mozjpeg: true }).toBuffer()
// If successful, update the mime type
if (compressedBuffer.length <= MAX_FILE_SIZE) {
return { buffer: compressedBuffer, mimeType: "image/jpeg" }
}
} else if (mimeType === "image/webp" || metadata.format === "webp") {
compressedBuffer = await sharpInstance.webp({ quality }).toBuffer()
if (compressedBuffer.length <= MAX_FILE_SIZE) {
return { buffer: compressedBuffer, mimeType }
}
} else {
// For JPEG and other formats, use JPEG compression
compressedBuffer = await sharpInstance.jpeg({ quality, mozjpeg: true }).toBuffer()
if (compressedBuffer.length <= MAX_FILE_SIZE) {
return { buffer: compressedBuffer, mimeType: "image/jpeg" }
}
}
// If still too large, reduce quality for next attempt
quality = Math.max(20, quality - 15)
}
// If we couldn't compress enough, resize the image as a last resort
const resizeFactor = Math.sqrt(targetSize / buffer.length)
const resizedBuffer = await sharp(buffer)
.resize({
width: Math.round((metadata.width || 1920) * resizeFactor),
height: Math.round((metadata.height || 1080) * resizeFactor),
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 70, mozjpeg: true })
.toBuffer()
return { buffer: resizedBuffer, mimeType: "image/jpeg" }
} catch (error) {
// If compression fails for any reason, log the error and return the original
console.error("Failed to compress image:", error)
return { buffer, mimeType }
}
}
/**
* Checks if an image buffer exceeds the maximum file size
*
* @param buffer - The image buffer to check
* @returns True if the image needs compression
*/
export function needsCompression(buffer: Buffer): boolean {
return buffer.length > MAX_FILE_SIZE
}
/**
* Gets a human-readable size string
*
* @param bytes - The size in bytes
* @returns A formatted string like "5.2 MB"
*/
export function formatFileSize(bytes: number): string {
const mb = bytes / (1024 * 1024)
return `${mb.toFixed(1)} MB`
}

View file

@ -1,6 +1,7 @@
import * as vscode from "vscode"
import fs from "fs/promises"
import * as path from "path"
import { compressImageIfNeeded, needsCompression, formatFileSize } from "./image-compression"
export async function selectImages(): Promise<string[]> {
const options: vscode.OpenDialogOptions = {
@ -20,9 +21,33 @@ export async function selectImages(): Promise<string[]> {
return await Promise.all(
fileUris.map(async (uri) => {
const imagePath = uri.fsPath
const buffer = await fs.readFile(imagePath)
let buffer = await fs.readFile(imagePath)
let mimeType = getMimeType(imagePath)
// Check if compression is needed
if (needsCompression(buffer)) {
const originalSize = formatFileSize(buffer.length)
// Show a progress notification while compressing
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: `Compressing large image (${originalSize})...`,
cancellable: false,
},
async () => {
const compressed = await compressImageIfNeeded(buffer, mimeType)
buffer = compressed.buffer
mimeType = compressed.mimeType
// Show info about compression
const newSize = formatFileSize(buffer.length)
vscode.window.showInformationMessage(`Image compressed from ${originalSize} to ${newSize}`)
},
)
}
const base64 = buffer.toString("base64")
const mimeType = getMimeType(imagePath)
const dataUrl = `data:${mimeType};base64,${base64}`
return dataUrl
}),

View file

@ -505,6 +505,7 @@
"sanitize-filename": "^1.6.3",
"say": "^0.16.0",
"serialize-error": "^12.0.0",
"sharp": "^0.34.4",
"simple-git": "^3.27.0",
"socket.io-client": "^4.8.1",
"sound-play": "^1.1.0",