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",
|
"@types/pdfkit": "^0.17.6",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"better-auth": "^1.6.10",
|
"better-auth": "^1.6.10",
|
||||||
|
"browser-image-compression": "^2.0.2",
|
||||||
"bullmq": "^5.76.8",
|
"bullmq": "^5.76.8",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@@ -121,6 +121,9 @@ importers:
|
|||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.6.10
|
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)
|
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:
|
bullmq:
|
||||||
specifier: ^5.76.8
|
specifier: ^5.76.8
|
||||||
version: 5.76.8
|
version: 5.76.8
|
||||||
@@ -2911,6 +2914,9 @@ packages:
|
|||||||
brotli@1.3.3:
|
brotli@1.3.3:
|
||||||
resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==}
|
resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==}
|
||||||
|
|
||||||
|
browser-image-compression@2.0.2:
|
||||||
|
resolution: {integrity: sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==}
|
||||||
|
|
||||||
browser-or-node@2.1.1:
|
browser-or-node@2.1.1:
|
||||||
resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==}
|
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).
|
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
|
hasBin: true
|
||||||
|
|
||||||
|
uzip@0.20201231.0:
|
||||||
|
resolution: {integrity: sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==}
|
||||||
|
|
||||||
vary@1.1.2:
|
vary@1.1.2:
|
||||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -8518,6 +8527,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
|
|
||||||
|
browser-image-compression@2.0.2:
|
||||||
|
dependencies:
|
||||||
|
uzip: 0.20201231.0
|
||||||
|
|
||||||
browser-or-node@2.1.1: {}
|
browser-or-node@2.1.1: {}
|
||||||
|
|
||||||
browserify-zlib@0.2.0:
|
browserify-zlib@0.2.0:
|
||||||
@@ -11672,6 +11685,8 @@ snapshots:
|
|||||||
|
|
||||||
uuid@8.3.2: {}
|
uuid@8.3.2: {}
|
||||||
|
|
||||||
|
uzip@0.20201231.0: {}
|
||||||
|
|
||||||
vary@1.1.2: {}
|
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):
|
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 { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants';
|
||||||
import { runTesseract } from '@/lib/ocr/tesseract-client';
|
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 ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ParsedReceipt {
|
interface ParsedReceipt {
|
||||||
@@ -301,7 +336,13 @@ export function ScanShell() {
|
|||||||
};
|
};
|
||||||
}, [imagePreview]);
|
}, [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);
|
if (imagePreview) URL.revokeObjectURL(imagePreview);
|
||||||
setImagePreview(URL.createObjectURL(file));
|
setImagePreview(URL.createObjectURL(file));
|
||||||
setState({ kind: 'processing', engine: 'tesseract' });
|
setState({ kind: 'processing', engine: 'tesseract' });
|
||||||
|
|||||||
Reference in New Issue
Block a user