/** * Client-side photo downscaling for pickup uploads. * * Why this exists: the driver app used to PUT the raw camera file * straight at PocketBase. PocketBase's request logs show what that * cost us — on 2026-10-12 every single attempt came back `400 * unexpected EOF` (86 of 86, median 7.6s, max 15.6s). That error is * Go's multipart parser hitting the end of the request body before * the declared Content-Length arrived: a modern Android camera * produces a 3–8 MB file, and on mobile data the transfer is getting * cut off partway through. Not a PocketBase mime or schema problem — * `order_item_attachements` has `mimeTypes=null`, so nothing was * rejecting the format. * * Size choice (2026-10-17): these photos are mostly supplier invoices * that the WFDASH AI reads on Collection Confirm. At 1200px / q70 a * landscape invoice shot sideways left stock codes too blurry to read * (the AI turned "051315" into "0515315"). 2000px / q80 keeps small * print legible and still lands around 400–600 KB — an order of * magnitude below the multi-MB camera files that failed in transit. */ /** Longest edge, in pixels, of the uploaded image. */ export const MAX_PHOTO_EDGE = 2000; /** JPEG encoder quality, 0–1. */ export const PHOTO_QUALITY = 0.8; /** * Below this, the file is already small enough that re-encoding costs * the driver time (and a visible spinner) for no meaningful saving. * 150 KB is comfortably inside a reliable mobile upload. */ export const SKIP_BELOW_BYTES = 150 * 1024; /** * Formats we try to re-encode. HEIC/HEIF is included (since 2026-10-20): * iPhones send HEIC, Safari on iOS/macOS can decode it into a canvas, and * WFDASH's AI and most desktop browsers can't read HEIC at all — the first * HEIC invoice photos went unread. Where the browser can't decode HEIC * (Chrome), loadImage fails and the original is uploaded as before. */ const REENCODABLE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif']); function isHeic(file: File): boolean { return /^image\/hei[cf]/i.test(file.type) || /\.(heic|heif)$/i.test(file.name); } function loadImage(url: string): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error('decode failed')); img.src = url; }); } /** * Downscale + re-encode `file` for upload. * * Never throws and never returns null: on any failure it hands back the * original file. Compression here is an optimisation, not a * correctness requirement — turning a photo that might have uploaded * fine into an error would be strictly worse than trying and failing * the way we do today. Callers can treat the return value as * "the best file we can produce", so the happy path stays simple. */ export async function compressPhoto(file: File): Promise { const heic = isHeic(file); // A HEIC is always converted, however small: the point is the format. if (!heic && file.size <= SKIP_BELOW_BYTES) return file; if (!heic && !REENCODABLE_TYPES.has(file.type)) return file; // Not available during SSR. Only ever called from a click handler in // the browser, but a guard is cheaper than a confusing crash. if (typeof document === 'undefined') return file; let objectUrl: string | null = null; try { objectUrl = URL.createObjectURL(file); const img = await loadImage(objectUrl); // Guard against absurd intrinsic sizes before allocating a canvas; // some devices report bogus dimensions for corrupt files and a // canvas at 99999px would take the tab down with it. if (!img.naturalWidth || !img.naturalHeight) return file; if (img.naturalWidth > 20000 || img.naturalHeight > 20000) return file; const longest = Math.max(img.naturalWidth, img.naturalHeight); // Only shrink. Upscaling a small photo wastes bytes and detail. A // HEIC is still re-encoded (at its own size) to get a JPEG. if (longest <= MAX_PHOTO_EDGE && !heic) return file; const scale = Math.min(1, MAX_PHOTO_EDGE / longest); const width = Math.max(1, Math.round(img.naturalWidth * scale)); const height = Math.max(1, Math.round(img.naturalHeight * scale)); const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); if (!ctx) return file; // JPEG has no alpha channel; without this a transparent PNG comes // out with black backgrounds where the alpha was. ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, width, height); ctx.drawImage(img, 0, 0, width, height); const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', PHOTO_QUALITY), ); if (!blob) return file; // If re-encoding somehow produced something bigger, keep the // original rather than making the upload worse — except for HEIC, // where a readable JPEG beats a smaller unreadable file. if (!heic && blob.size >= file.size) return file; const stem = file.name.replace(/\.[^.]+$/, '') || 'pickup'; return new File([blob], `${stem}.jpg`, { type: 'image/jpeg', lastModified: Date.now(), }); } catch { // Decode failure, canvas unavailable, out of memory — all handled // the same way: try anyway with what we were given. return file; } finally { if (objectUrl) URL.revokeObjectURL(objectUrl); } }