feat(scan): compress phone-photo receipts before upload (browser-image-compression)
Phase 3 — wires `browser-image-compression` into the scan-shell so 4-12 MB
phone photos get crushed to ~500 KB in a WebWorker before any other work
happens. Receipts come back from tesseract + the AI parse much faster on
mobile bandwidth, and the server's sharp pipeline has less to chew on.
compressReceiptIfHeavy(file):
- Pass-through for SVGs / PDFs / non-images
- Pass-through for files already under 1 MB
- Otherwise: imageCompression with maxSizeMB: 0.5, maxWidthOrHeight:
2000, useWebWorker: true, preserveExif: false (auto-rotate to EXIF
orientation then strip metadata so the receipt isn't sideways)
- PNG → JPEG transcode (smaller for natural photo content)
- Initial quality 0.85 — Tesseract's sweet spot for receipt text
- Lazy-loaded import: the WebWorker bundle isn't on the critical path
- try/catch fallback: if compression itself throws, fall through to
the original file so a corner-case bug never blocks a save
Wired into handleFile(rawFile) before tesseract runs and before the
receipt is sent to /api/v1/expenses/scan-receipt. Downstream upload
through handleSubmit() also benefits because the same compressed File
flows through.
Concrete impact for a 12 MP iPhone receipt (~8 MB):
Before: 8 MB upload, 8 MB tesseract input
After: ~500 KB upload, 2000px max edge tesseract input
Bandwidth + battery + perceived latency win on the mobile expense
scanner path. No behaviour change for desktop file uploads under 1 MB.
1298/1298 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@@ -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):
|
||||
|
||||
@@ -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<File> {
|
||||
// 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' });
|
||||
|
||||
Reference in New Issue
Block a user