feat(base): client-side image compression in ImageUploadService
Optional opt-in resize + re-encode of files before the pict-rs POST, behind a new `compress` option on ImageUploadOptions / `<ImageUpload :compress>` prop. Pict-rs stores originals at full resolution forever and `process.webp?resize=…` URLs only shape *delivery* — without compression, a 5–8 MB phone photo lands on disk untouched. Defaults when enabled: 1920 px max edge, WebP, q=0.85, target ~1 MB, Web Worker. Skips compression for files already comfortably under the size target, and keeps the original if re-encoding would make it larger. Uses browser-image-compression (MIT, ~50 KB, mature) — handles EXIF orientation internally (canvas drawImage doesn't auto-rotate, which is the classic "portrait photo lands sideways" bug we'd otherwise own) and falls back gracefully on encoder failure (HEIC, etc.). Default is `compress: false` so existing market and profile call sites keep current behavior; rollout tracked in #59. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
691f8df830
commit
16608e0d60
4 changed files with 128 additions and 18 deletions
57
package-lock.json
generated
57
package-lock.json
generated
|
|
@ -13,6 +13,7 @@
|
|||
"@vueuse/components": "^12.5.0",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"@vueuse/integrations": "^13.6.0",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
|
|
@ -145,7 +146,6 @@
|
|||
"integrity": "sha512-l+lkXCHS6tQEc5oUpK28xBOZ6+HwaH7YwoYQbLFiYb4nS2/l1tKnZEtEWkD0GuiYdvArf9qBS0XlQGXzPMsNqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
|
|
@ -2651,7 +2651,6 @@
|
|||
"integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.1",
|
||||
"fs-extra": "^9.0.1",
|
||||
|
|
@ -5718,7 +5717,6 @@
|
|||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
|
|
@ -6061,6 +6059,15 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/browser-image-compression": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz",
|
||||
"integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uzip": "0.20201231.0"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.24.4",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
|
||||
|
|
@ -6081,7 +6088,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001688",
|
||||
"electron-to-chromium": "^1.5.73",
|
||||
|
|
@ -7628,6 +7634,17 @@
|
|||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encoding": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
||||
|
|
@ -8381,7 +8398,6 @@
|
|||
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz",
|
||||
"integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
|
|
@ -8918,6 +8934,20 @@
|
|||
"ms": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/idb": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
||||
|
|
@ -11733,7 +11763,6 @@
|
|||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
|
|
@ -12377,7 +12406,6 @@
|
|||
"integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
|
|
@ -13387,8 +13415,7 @@
|
|||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.12.tgz",
|
||||
"integrity": "sha512-bT0hJo91FtncsAMSsMzUkoo/iEU0Xs5xgFgVC9XmdM9bw5MhZuQFjPNl6wxAE0SiQF/YTZJa+PndGWYSDtuxAg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss-animate": {
|
||||
"version": "1.0.7",
|
||||
|
|
@ -13523,7 +13550,6 @@
|
|||
"integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.8.2",
|
||||
|
|
@ -13753,7 +13779,6 @@
|
|||
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -13953,6 +13978,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uzip": {
|
||||
"version": "0.20201231.0",
|
||||
"resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz",
|
||||
"integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/validate-npm-package-license": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
|
||||
|
|
@ -14004,7 +14035,6 @@
|
|||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
|
@ -14229,7 +14259,6 @@
|
|||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz",
|
||||
"integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.13",
|
||||
"@vue/compiler-sfc": "3.5.13",
|
||||
|
|
@ -14682,7 +14711,6 @@
|
|||
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
|
|
@ -14940,7 +14968,6 @@
|
|||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@
|
|||
"@vueuse/components": "^12.5.0",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"@vueuse/integrations": "^13.6.0",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ import { Camera, X, Loader2, AlertCircle, Image } from 'lucide-vue-next'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { ImageUploadService, UploadedImage } from '../services/ImageUploadService'
|
||||
import type { ImageUploadService, UploadedImage, CompressOptions } from '../services/ImageUploadService'
|
||||
|
||||
interface ImageWithMetadata extends UploadedImage {
|
||||
isPrimary: boolean
|
||||
|
|
@ -175,6 +175,12 @@ const props = defineProps<{
|
|||
disabled?: boolean
|
||||
placeholder?: string
|
||||
allowCamera?: boolean
|
||||
/**
|
||||
* Client-side resize + re-encode before upload. Pass `true` for the
|
||||
* service defaults (1920px max edge, WebP, ~1MB target), or an object
|
||||
* to tune individual knobs.
|
||||
*/
|
||||
compress?: boolean | CompressOptions
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -379,7 +385,8 @@ const uploadFiles = async (files: File[]) => {
|
|||
|
||||
try {
|
||||
const uploadOptions = {
|
||||
maxSizeMB: maxSizeMB.value
|
||||
maxSizeMB: maxSizeMB.value,
|
||||
compress: props.compress,
|
||||
}
|
||||
|
||||
// Upload files with better error handling
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import imageCompression from 'browser-image-compression'
|
||||
import { BaseService } from '@/core/base/BaseService'
|
||||
import type { ServiceMetadata } from '@/core/base/BaseService'
|
||||
import appConfig from '@/app.config'
|
||||
|
|
@ -13,10 +14,36 @@ export interface UploadedImage {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side compression knobs. Resize first, then re-encode.
|
||||
*
|
||||
* Defaults target a typical banner/poster: longest edge 1920px, WebP
|
||||
* at q=0.85, aim for ~1MB output. EXIF orientation is handled by the
|
||||
* library (canvas drawImage doesn't auto-rotate, which is the classic
|
||||
* "portrait photo lands sideways" bug).
|
||||
*/
|
||||
export interface CompressOptions {
|
||||
maxSizeMB?: number
|
||||
maxWidthOrHeight?: number
|
||||
initialQuality?: number
|
||||
fileType?: 'image/webp' | 'image/jpeg'
|
||||
useWebWorker?: boolean
|
||||
}
|
||||
|
||||
export interface ImageUploadOptions {
|
||||
maxSizeMB?: number
|
||||
acceptedTypes?: string[]
|
||||
generateThumbnail?: boolean
|
||||
/** Enable client-side resize + re-encode before upload. */
|
||||
compress?: boolean | CompressOptions
|
||||
}
|
||||
|
||||
const DEFAULT_COMPRESS: Required<CompressOptions> = {
|
||||
maxSizeMB: 1,
|
||||
maxWidthOrHeight: 1920,
|
||||
initialQuality: 0.85,
|
||||
fileType: 'image/webp',
|
||||
useWebWorker: true,
|
||||
}
|
||||
|
||||
export interface ImageUrlOptions {
|
||||
|
|
@ -76,8 +103,12 @@ export class ImageUploadService extends BaseService {
|
|||
// Validate file
|
||||
this.validateFile(file, options)
|
||||
|
||||
// Optional client-side resize + re-encode. Keeps phone photos
|
||||
// (5–8MB originals) from hitting pict-rs as full-resolution files.
|
||||
const fileToUpload = await this.maybeCompress(file, options)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('images[]', file)
|
||||
formData.append('images[]', fileToUpload)
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/image`, {
|
||||
method: 'POST',
|
||||
|
|
@ -291,6 +322,50 @@ export class ImageUploadService extends BaseService {
|
|||
return alias
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize + re-encode the file when compression is requested. Returns
|
||||
* the original file untouched when compression is off, the file is
|
||||
* already smaller than the target, or when re-encoding produces a
|
||||
* larger blob than the input (rare but possible on already-optimized
|
||||
* sources).
|
||||
*
|
||||
* The library handles EXIF orientation internally and falls back to
|
||||
* JPEG if the browser can't encode WebP.
|
||||
*/
|
||||
private async maybeCompress(file: File, options: ImageUploadOptions): Promise<File> {
|
||||
if (!options.compress) return file
|
||||
|
||||
const opts: Required<CompressOptions> = {
|
||||
...DEFAULT_COMPRESS,
|
||||
...(typeof options.compress === 'object' ? options.compress : {}),
|
||||
}
|
||||
|
||||
// Skip if the source is already smaller than the target and within
|
||||
// dimension limits (we can't cheaply check dimensions without
|
||||
// decoding, so size-only is the pragmatic short-circuit).
|
||||
if (file.size <= opts.maxSizeMB * 1024 * 1024 * 0.6) {
|
||||
this.debug(`Skipping compression: ${file.name} already ${(file.size / 1024 / 1024).toFixed(2)}MB`)
|
||||
return file
|
||||
}
|
||||
|
||||
try {
|
||||
const compressed = await imageCompression(file, opts)
|
||||
if (compressed.size >= file.size) {
|
||||
this.debug(`Compression made ${file.name} larger; keeping original`)
|
||||
return file
|
||||
}
|
||||
this.debug(
|
||||
`Compressed ${file.name}: ${(file.size / 1024 / 1024).toFixed(2)}MB → ${(compressed.size / 1024 / 1024).toFixed(2)}MB`
|
||||
)
|
||||
return compressed
|
||||
} catch (err) {
|
||||
// HEIC / encoder edge cases — fall back to the original file so the
|
||||
// upload still succeeds. Server-side processing handles delivery.
|
||||
this.debug('Compression failed, uploading original:', err)
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file before upload
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue