perf: Remove Tesseract OCR - saves ~45MB on mobile

- Removed Tesseract.js files from precache (~45MB)
- Scanner now uses only Gemini AI (more accurate, less data)
- Offline scans queued for later processing when online
- App download from ~50MB to ~5MB

BREAKING: Local offline OCR no longer available
Use Gemini AI instead (requires network for scanning)
This commit is contained in:
2025-12-25 23:39:08 +01:00
parent 462d27ea7b
commit f0f36e9c03
17 changed files with 55 additions and 2190 deletions

View File

@@ -3,8 +3,6 @@
import { useState, useCallback, useRef } from 'react';
import { BottleMetadata } from '@/types/whisky';
import { processImageForAI, ProcessedImage } from '@/utils/image-processing';
import { analyzeLocalOcr, LocalOcrResult, terminateOcrWorker } from '@/lib/ocr/local-engine';
import { isTesseractReady, isOnline } from '@/lib/ocr/scanner-utils';
import { analyzeLabelWithGemini } from '@/app/actions/gemini-vision';
import { generateDummyMetadata } from '@/utils/generate-dummy-metadata';
import { db } from '@/lib/db';
@@ -12,22 +10,19 @@ import { db } from '@/lib/db';
export type ScanStatus =
| 'idle'
| 'compressing'
| 'analyzing_local'
| 'analyzing_cloud'
| 'analyzing'
| 'complete'
| 'queued'
| 'error';
export interface ScanResult {
status: ScanStatus;
localResult: Partial<BottleMetadata> | null;
cloudResult: BottleMetadata | null;
mergedResult: BottleMetadata | null;
processedImage: ProcessedImage | null;
error: string | null;
perf: {
compression: number;
localOcr: number;
cloudVision: number;
total: number;
} | null;
@@ -35,25 +30,24 @@ export interface ScanResult {
export interface UseScannerOptions {
locale?: string;
onLocalComplete?: (result: Partial<BottleMetadata>) => void;
onCloudComplete?: (result: BottleMetadata) => void;
onComplete?: (result: BottleMetadata) => void;
}
/**
* React hook for the hybrid Local OCR + Cloud Vision scanning flow
* React hook for Gemini Vision scanning
*
* Flow:
* 1. Compress image (browser-side)
* 2. Run local OCR (tesseract.js) → immediate preview
* 3. Run cloud vision (Gemini) → refined results
* 4. Merge results (cloud overrides local, except user-edited fields)
* 2. Run cloud vision (Gemini) → accurate results
* 3. Show editor for user refinement
*
* No local OCR - saves ~45MB of Tesseract files
*/
export function useScanner(options: UseScannerOptions = {}) {
const { locale = 'en', onLocalComplete, onCloudComplete } = options;
const { locale = 'en', onComplete } = options;
const [result, setResult] = useState<ScanResult>({
status: 'idle',
localResult: null,
cloudResult: null,
mergedResult: null,
processedImage: null,
@@ -64,69 +58,25 @@ export function useScanner(options: UseScannerOptions = {}) {
// Track which fields the user has manually edited
const dirtyFieldsRef = useRef<Set<string>>(new Set());
/**
* Mark a field as user-edited (won't be overwritten by cloud results)
*/
const markFieldDirty = useCallback((field: string) => {
dirtyFieldsRef.current.add(field);
}, []);
/**
* Clear all dirty field markers
*/
const clearDirtyFields = useCallback(() => {
dirtyFieldsRef.current.clear();
}, []);
/**
* Merge local and cloud results, respecting user edits
*/
const mergeResults = useCallback((
local: Partial<BottleMetadata> | null,
cloud: BottleMetadata | null,
dirtyFields: Set<string>
): BottleMetadata | null => {
if (!cloud && !local) return null;
if (!cloud) {
return {
name: local?.name || null,
distillery: local?.distillery || null,
abv: local?.abv || null,
age: local?.age || null,
vintage: local?.vintage || null,
is_whisky: true,
confidence: 50,
} as BottleMetadata;
}
if (!local) return cloud;
// Start with cloud result as base
const merged = { ...cloud };
// For each field, keep local value if user edited it
for (const field of dirtyFields) {
if (field in local && (local as any)[field] !== undefined) {
(merged as any)[field] = (local as any)[field];
}
}
return merged;
}, []);
/**
* Main scan handler
*/
const handleScan = useCallback(async (file: File) => {
const perfStart = performance.now();
let perfCompression = 0;
let perfLocalOcr = 0;
let perfCloudVision = 0;
// Reset state
clearDirtyFields();
setResult({
status: 'compressing',
localResult: null,
cloudResult: null,
mergedResult: null,
processedImage: null,
@@ -140,23 +90,11 @@ export function useScanner(options: UseScannerOptions = {}) {
const processedImage = await processImageForAI(file);
perfCompression = performance.now() - compressStart;
setResult(prev => ({
...prev,
status: 'analyzing_local',
processedImage,
}));
// Step 2: Check if we're offline or tesseract isn't ready
const online = isOnline();
const tesseractReady = await isTesseractReady();
// If offline and tesseract not ready, show editor with dummy data
// Queue image for later processing when back online
if (!online && !tesseractReady) {
console.log('[useScanner] Offline + no tesseract cache → showing editor with dummy data');
// Step 2: Check if offline
if (!navigator.onLine) {
console.log('[useScanner] Offline → queuing for later');
const dummyMetadata = generateDummyMetadata(file);
// Queue for later processing
await db.pending_scans.add({
temp_id: `temp_${Date.now()}`,
imageBase64: processedImage.base64,
@@ -165,77 +103,26 @@ export function useScanner(options: UseScannerOptions = {}) {
metadata: dummyMetadata as any,
});
// Show editor with dummy data (status: complete so editor opens!)
setResult(prev => ({
...prev,
status: 'complete',
setResult({
status: 'queued',
cloudResult: null,
mergedResult: dummyMetadata,
processedImage,
error: null,
perf: {
compression: perfCompression,
localOcr: 0,
cloudVision: 0,
total: performance.now() - perfStart,
},
}));
});
return;
}
// Step 3: Run local OCR (testing new line-by-line matching)
let localResult: Partial<BottleMetadata> | null = null;
try {
const localStart = performance.now();
console.log('[useScanner] Running local OCR...');
const ocrResult = await analyzeLocalOcr(processedImage.file, 10000);
perfLocalOcr = performance.now() - localStart;
if (ocrResult.rawText && ocrResult.rawText.length > 10) {
localResult = {
name: ocrResult.name || undefined,
distillery: ocrResult.distillery || undefined,
abv: ocrResult.abv || undefined,
age: ocrResult.age || undefined,
vintage: ocrResult.vintage || undefined,
};
// Update state with local results
const localMerged = mergeResults(localResult, null, dirtyFieldsRef.current);
setResult(prev => ({
...prev,
localResult,
mergedResult: localMerged,
}));
onLocalComplete?.(localResult);
console.log('[useScanner] Local OCR complete:', localResult);
}
} catch (ocrError) {
console.warn('[useScanner] Local OCR failed:', ocrError);
}
// Step 4: If offline, use local results only
if (!online) {
console.log('[useScanner] Offline → using local results only');
const offlineMerged = mergeResults(localResult, null, dirtyFieldsRef.current);
setResult(prev => ({
...prev,
status: 'complete',
mergedResult: offlineMerged || generateDummyMetadata(file),
perf: {
compression: perfCompression,
localOcr: perfLocalOcr,
cloudVision: 0,
total: performance.now() - perfStart,
},
}));
return;
}
// Step 5: Run cloud vision analysis
// Step 3: Run Gemini Vision
setResult(prev => ({
...prev,
status: 'analyzing_cloud',
status: 'analyzing',
processedImage,
}));
const cloudStart = performance.now();
@@ -244,40 +131,38 @@ export function useScanner(options: UseScannerOptions = {}) {
if (cloudResponse.success && cloudResponse.data) {
const cloudResult = cloudResponse.data;
const finalMerged = mergeResults(localResult, cloudResult, dirtyFieldsRef.current);
onComplete?.(cloudResult);
console.log('[useScanner] Gemini complete:', cloudResult);
onCloudComplete?.(cloudResult);
console.log('[useScanner] Cloud vision complete:', cloudResult);
setResult(prev => ({
...prev,
setResult({
status: 'complete',
cloudResult,
mergedResult: finalMerged,
mergedResult: cloudResult,
processedImage,
error: null,
perf: {
compression: perfCompression,
localOcr: perfLocalOcr,
cloudVision: perfCloudVision,
total: performance.now() - perfStart,
},
}));
});
} else {
// Cloud failed, fall back to local results
console.warn('[useScanner] Cloud vision failed:', cloudResponse.error);
const fallbackMerged = mergeResults(localResult, null, dirtyFieldsRef.current);
// Gemini failed - show editor with dummy data
console.warn('[useScanner] Gemini failed:', cloudResponse.error);
const fallback = generateDummyMetadata(file);
setResult(prev => ({
...prev,
setResult({
status: 'complete',
cloudResult: null,
mergedResult: fallback,
processedImage,
error: cloudResponse.error || null,
mergedResult: fallbackMerged || generateDummyMetadata(file),
perf: {
compression: perfCompression,
localOcr: perfLocalOcr,
cloudVision: perfCloudVision,
total: performance.now() - perfStart,
},
}));
});
}
} catch (error: any) {
@@ -288,22 +173,17 @@ export function useScanner(options: UseScannerOptions = {}) {
error: error.message || 'Scan failed',
perf: {
compression: perfCompression,
localOcr: perfLocalOcr,
cloudVision: perfCloudVision,
total: performance.now() - perfStart,
},
}));
}
}, [locale, mergeResults, clearDirtyFields, onLocalComplete, onCloudComplete]);
}, [locale, clearDirtyFields, onComplete]);
/**
* Reset scanner state
*/
const reset = useCallback(() => {
clearDirtyFields();
setResult({
status: 'idle',
localResult: null,
cloudResult: null,
mergedResult: null,
processedImage: null,
@@ -312,11 +192,7 @@ export function useScanner(options: UseScannerOptions = {}) {
});
}, [clearDirtyFields]);
/**
* Update the merged result (for user edits)
*/
const updateMergedResult = useCallback((updates: Partial<BottleMetadata>) => {
// Mark updated fields as dirty
Object.keys(updates).forEach(key => {
dirtyFieldsRef.current.add(key);
});
@@ -327,12 +203,8 @@ export function useScanner(options: UseScannerOptions = {}) {
}));
}, []);
/**
* Cleanup (terminate OCR worker)
*/
const cleanup = useCallback(() => {
terminateOcrWorker();
}, []);
// No cleanup needed without Tesseract
const cleanup = useCallback(() => { }, []);
return {
...result,