diff --git a/package.json b/package.json index fb5f6dd7..17a3b01d 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@types/pdfkit": "^0.17.6", "archiver": "^7.0.1", "better-auth": "^1.6.10", + "browser-image-compression": "^2.0.2", "bullmq": "^5.76.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41426831..414de234 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: better-auth: specifier: ^1.6.10 version: 1.6.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9))(mongodb@7.1.0(socks@2.8.8))(next@15.5.18(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6) + browser-image-compression: + specifier: ^2.0.2 + version: 2.0.2 bullmq: specifier: ^5.76.8 version: 5.76.8 @@ -2911,6 +2914,9 @@ packages: brotli@1.3.3: resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + browser-image-compression@2.0.2: + resolution: {integrity: sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==} + browser-or-node@2.1.1: resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==} @@ -5829,6 +5835,9 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + uzip@0.20201231.0: + resolution: {integrity: sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -8518,6 +8527,10 @@ snapshots: dependencies: base64-js: 1.5.1 + browser-image-compression@2.0.2: + dependencies: + uzip: 0.20201231.0 + browser-or-node@2.1.1: {} browserify-zlib@0.2.0: @@ -11672,6 +11685,8 @@ snapshots: uuid@8.3.2: {} + uzip@0.20201231.0: {} + vary@1.1.2: {} vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): diff --git a/src/components/scan/scan-shell.tsx b/src/components/scan/scan-shell.tsx index f0dfb266..1af8b251 100644 --- a/src/components/scan/scan-shell.tsx +++ b/src/components/scan/scan-shell.tsx @@ -25,6 +25,41 @@ import { cn } from '@/lib/utils'; import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants'; import { runTesseract } from '@/lib/ocr/tesseract-client'; +// Lazy-loaded compression — the worker bundle isn't on the critical path, +// and most users won't reach this code without first granting camera/file +// access, by which point the module is already paged in. +async function compressReceiptIfHeavy(file: File): Promise { + // Only compress raster images > ~1 MB. PDFs, SVGs, and small files pass + // through untouched. Magic-byte check via mime type — the caller is the + // file picker which trusts the picker output already. + if (!file.type.startsWith('image/') || file.type === 'image/svg+xml') return file; + if (file.size < 1 * 1024 * 1024) return file; + const { default: imageCompression } = await import('browser-image-compression'); + try { + const compressed = await imageCompression(file, { + maxSizeMB: 0.5, // ~500 KB target — plenty of resolution for OCR + maxWidthOrHeight: 2000, // tesseract.js's sweet spot for receipt text + useWebWorker: true, // off the main thread; UI stays responsive + // Auto-rotate to EXIF orientation, strip metadata. Phones often + // store the rotation as EXIF rather than rotating pixels; without + // this the receipt comes out sideways and OCR confidence tanks. + preserveExif: false, + fileType: file.type === 'image/png' ? 'image/jpeg' : file.type, + initialQuality: 0.85, + }); + // Browser-image-compression typings always say `File`, but in some + // runtimes the value comes through as a plain Blob. Belt-and-suspenders: + // wrap in a File so downstream FormData uses the original filename. + const blob = compressed as unknown as Blob; + if (typeof File !== 'undefined' && blob instanceof File) return blob; + return new File([blob], file.name, { type: blob.type }); + } catch { + // Fall back to the original — we don't want a corner-case compression + // bug to block the user from saving an expense. + return file; + } +} + // ─── Types ──────────────────────────────────────────────────────────────────── interface ParsedReceipt { @@ -301,7 +336,13 @@ export function ScanShell() { }; }, [imagePreview]); - async function handleFile(file: File) { + async function handleFile(rawFile: File) { + // Compress oversized phone photos to ~500 KB in a WebWorker BEFORE + // we hand the bytes to tesseract or the server. Receipts from 12MP + // cameras are usually 4-8 MB; this drops them to ~250-500 KB without + // visible quality loss for text OCR. Mobile bandwidth + the server's + // sharp pipeline both benefit. + const file = await compressReceiptIfHeavy(rawFile); if (imagePreview) URL.revokeObjectURL(imagePreview); setImagePreview(URL.createObjectURL(file)); setState({ kind: 'processing', engine: 'tesseract' });